Skip to content

Commit

Permalink
Core: Check and handle invalid app inventory data on Samsung devices
Browse files Browse the repository at this point in the history
`QUERY_ALL_PACKAGES` is a permission that is automatically granted, at least according to documentation.
Except, of course, on some Samsung devices running Android 14, because why not do things differently...

This PR prevents SD Maid from crashing in these cases. A specific error is shown.
The state is now detected and treated as a permission error and will show as incomplete-setup.

Fixes #1354
  • Loading branch information
d4rken committed Aug 6, 2024
1 parent 2215a0e commit cd03126
Show file tree
Hide file tree
Showing 40 changed files with 339 additions and 211 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,37 +38,24 @@ sealed class AKnownPkg(override val id: Pkg.Id) : Pkg {
ContextCompat.getDrawable(context, R.drawable.ic_default_app_icon_24)!!
}

data object AndroidSystem : AKnownPkg("android") {
override val labelRes: Int = R.string.apps_known_android_system_label
}
data object AndroidSystem : AKnownPkg("android")

data object GooglePlay : AKnownPkg("com.android.vending"), AppStore {
override val labelRes: Int = R.string.apps_known_installer_gplay_label
override val iconRes: Int = R.drawable.ic_baseline_gplay_24
override val urlGenerator: ((Pkg.Id) -> String) = {
"https://play.google.com/store/apps/details?id=${it.name}"
}
}

data object VivoAppStore : AKnownPkg("com.vivo.appstore"), AppStore {
override val labelRes: Int = R.string.apps_known_installer_vivo_label
}
data object VivoAppStore : AKnownPkg("com.vivo.appstore"), AppStore

data object OppoMarket : AKnownPkg("com.oppo.market"), AppStore {
override val labelRes: Int = R.string.apps_known_installer_oppo_label
}
data object OppoMarket : AKnownPkg("com.oppo.market"), AppStore

data object HuaweiAppGallery : AKnownPkg("com.huawei.appmarket"), AppStore {
override val labelRes: Int = R.string.apps_known_installer_huawei_label
}
data object HuaweiAppGallery : AKnownPkg("com.huawei.appmarket"), AppStore

data object SamsungAppStore : AKnownPkg("com.sec.android.app.samsungapps"), AppStore {
override val labelRes: Int = R.string.apps_known_installer_samsung_label
}
data object SamsungAppStore : AKnownPkg("com.sec.android.app.samsungapps"), AppStore

data object XiaomiAppStore : AKnownPkg("com.xiaomi.mipicks"), AppStore {
override val labelRes: Int = R.string.apps_known_installer_xiaomi_label
}
data object XiaomiAppStore : AKnownPkg("com.xiaomi.mipicks"), AppStore

companion object {
val values: List<AKnownPkg> = listOf(
Expand Down

This file was deleted.

9 changes: 0 additions & 9 deletions app-common-io/src/main/res/values/strings.xml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package eu.darken.sdmse.common.pkgs

import eu.darken.sdmse.common.ca.toCaString
import eu.darken.sdmse.common.error.HasLocalizedError
import eu.darken.sdmse.common.error.LocalizedError

class InvalidPkgInventoryException(
override val message: String
) : IllegalStateException(), HasLocalizedError {

override fun getLocalizedError(): LocalizedError = LocalizedError(
throwable = this,
label = R.string.pkgrepo_invalid_inventory_error_title.toCaString(),
description = R.string.pkgrepo_invalid_inventory_error_description.toCaString(),
)
}
130 changes: 67 additions & 63 deletions app-common-pkgs/src/main/java/eu/darken/sdmse/common/pkgs/PkgRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package eu.darken.sdmse.common.pkgs

import eu.darken.sdmse.common.collections.mutate
import eu.darken.sdmse.common.coroutine.AppScope
import eu.darken.sdmse.common.coroutine.DispatcherProvider
import eu.darken.sdmse.common.debug.Bugs
import eu.darken.sdmse.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.sdmse.common.debug.logging.Logging.Priority.ERROR
import eu.darken.sdmse.common.debug.logging.Logging.Priority.INFO
import eu.darken.sdmse.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.sdmse.common.debug.logging.asLog
import eu.darken.sdmse.common.debug.logging.log
import eu.darken.sdmse.common.debug.logging.logTag
import eu.darken.sdmse.common.files.GatewaySwitch
import eu.darken.sdmse.common.flow.DynamicStateFlow
import eu.darken.sdmse.common.flow.replayingShare
import eu.darken.sdmse.common.pkgs.features.Installed
import eu.darken.sdmse.common.pkgs.pkgops.PkgOps
Expand All @@ -21,51 +25,50 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.plus
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.reflect.KClass

@Singleton
class PkgRepo @Inject constructor(
@AppScope private val appScope: CoroutineScope,
dispatcherProvider: DispatcherProvider,
pkgEventListener: PackageEventListener,
private val pkgSources: Set<@JvmSuppressWildcards PkgDataSource>,
private val gatewaySwitch: GatewaySwitch,
private val pkgOps: PkgOps,
private val userManager: UserManager2,
) {

data class CacheContainer(
val isSetupDone: Boolean = false,
val isInitialized: Boolean = false,
val pkgData: Map<CacheKey, CachedInfo> = emptyMap(),
private val cache = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
log(TAG, INFO) { "Initializing pkg cache" }
generateCacheContainer().also {
isPreInitInternal.value = false
}
}

data class PkgData(
internal val pkgMap: Map<CacheKey, CachedInfo> = emptyMap(),
val error: Exception? = null,
) {

val pkgs: Collection<Installed>
get() {
if (error != null) throw error
return pkgMap.values.mapNotNull { it.data }
}

internal val pkgCount: Int
get() = pkgData.count { it.value.data != null }
get() = pkgMap.count { it.value.data != null }
}

private val cacheLock = Mutex()
private val pkgCache = MutableStateFlow(CacheContainer())

val pkgs: Flow<Collection<Installed>> = pkgCache
.filter { it.isInitialized }
.map { it.pkgData }
.map { cachedInfo -> cachedInfo.values.mapNotNull { it.data } }
.onStart {
cacheLock.withLock {
if (!pkgCache.value.isInitialized) {
log(TAG, INFO) { "Init due to pkgs subscription" }
load()
}
}
}
private val isPreInitInternal = MutableStateFlow(true)
val isPreInit: Flow<Boolean> = isPreInitInternal

val data: Flow<PkgData> = cache.flow
.replayingShare(appScope)

init {
Expand All @@ -78,15 +81,17 @@ class PkgRepo @Inject constructor(
.launchIn(appScope)
}

private suspend fun load() {
log(TAG) { "load()" }
pkgCache.value = CacheContainer(
isInitialized = true,
pkgData = generatePkgcache()
)
private suspend fun generateCacheContainer(): PkgData {
log(TAG) { "generateCacheContainer()..." }
return try {
PkgData(pkgMap = gatherPkgData())
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to load pkg data: ${e.asLog()}" }
PkgData(error = e)
}
}

private suspend fun generatePkgcache(): Map<CacheKey, CachedInfo> {
private suspend fun gatherPkgData(): Map<CacheKey, CachedInfo> {
log(TAG, INFO) { "generatePkgcache()..." }
val start = System.currentTimeMillis()
return gatewaySwitch.useRes {
Expand Down Expand Up @@ -142,33 +147,49 @@ class PkgRepo @Inject constructor(
}
}

suspend fun refresh(
id: Pkg.Id,
userHandle: UserHandle2? = null
): Collection<Installed> {
log(TAG) { "refresh(): $id" }
// TODO refreshing the whole cache is inefficient, implement single target refresh?
refresh()
return queryCache(id, userHandle).mapNotNull { it.data }
}

suspend fun refresh(): Collection<Installed> {
val before = cache.value()
log(TAG) { "refresh()... (before=${before.pkgCount})" }
val after = cache.updateBlocking { generateCacheContainer() }
log(TAG, INFO) { "...refresh()ed (after=${after.pkgCount})" }
return after.pkgs
}

private suspend fun queryCache(
pkgId: Pkg.Id,
userHandle: UserHandle2?,
): Set<CachedInfo> = cacheLock.withLock {
if (!pkgCache.value.isInitialized) {
log(TAG) { "Package cache doesn't exist yet..." }
load()
}
): Set<CachedInfo> {
val systemHandle = userManager.systemUser().handle

val infos = pkgCache.value.pkgData.values.filter {
val infos = cache.value().pkgMap.filter {
it.key.pkgId == pkgId && (userHandle == null || userHandle == systemHandle || it.key.userHandle == userHandle)
}
if (infos.isNotEmpty()) return@withLock infos.toSet()
if (infos.isNotEmpty()) return infos.values.toSet()

log(TAG, VERBOSE) { "Cache miss for $pkgId:$userHandle" }

// We didn't have any cache matches
if (userHandle != null) {
// Save the cache miss for better performance
return if (userHandle != null) {
val key = CacheKey(pkgId, userHandle)
val cacheInfo = CachedInfo(key, null)

pkgCache.value = pkgCache.value.copy(
pkgData = pkgCache.value.pkgData.mutate {
this[key] = cacheInfo
}
)
cache.updateBlocking {
this.copy(
pkgMap = this.pkgMap.mutate {
this[key] = cacheInfo
}
)
}

setOf(cacheInfo)
} else {
Expand All @@ -181,23 +202,6 @@ class PkgRepo @Inject constructor(
userHandle: UserHandle2?,
): Collection<Installed> = queryCache(pkgId, userHandle).mapNotNull { it.data }

suspend fun refresh(
id: Pkg.Id,
userHandle: UserHandle2? = null
): Collection<Installed> {
log(TAG) { "refresh(): $id" }
// TODO refreshing the whole cache is inefficient, implement single target refresh?
cacheLock.withLock { load() }
return queryCache(id, userHandle).mapNotNull { it.data }
}

suspend fun refresh(): Collection<Installed> = cacheLock.withLock {
log(TAG) { "refresh()... (before=${pkgCache.value.pkgCount})" }
load()
log(TAG, INFO) { "...refresh()ed (after=${pkgCache.value.pkgCount})" }
pkgCache.value.pkgData.mapNotNull { it.value.data }
}

data class CacheKey(
val pkgId: Pkg.Id,
val userHandle: UserHandle2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@ package eu.darken.sdmse.common.pkgs

import eu.darken.sdmse.common.pkgs.features.Installed
import eu.darken.sdmse.common.user.UserHandle2
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map

fun PkgRepo.pkgs(): Flow<Collection<Installed>> = data.map { it.pkgs }

suspend fun PkgRepo.currentPkgs(): Collection<Installed> = this.pkgs.first()
suspend fun PkgRepo.current(): Collection<Installed> = pkgs().first()

suspend fun PkgRepo.getPkg(
suspend fun PkgRepo.get(
pkgId: Pkg.Id,
): Collection<Installed> = query(pkgId, null)

suspend fun PkgRepo.getPkg(
suspend fun PkgRepo.get(
pkgId: Pkg.Id,
userHandle: UserHandle2?,
): Installed? = query(pkgId, userHandle).singleOrNull()


suspend fun PkgRepo.getPkg(
suspend fun PkgRepo.get(
installId: Installed.InstallId
): Installed? = query(installId.pkgId, installId.userHandle).singleOrNull()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import eu.darken.sdmse.common.debug.logging.log
import eu.darken.sdmse.common.debug.logging.logTag
import eu.darken.sdmse.common.hasApiLevel
import eu.darken.sdmse.common.permissions.Permission
import eu.darken.sdmse.common.pkgs.InvalidPkgInventoryException
import eu.darken.sdmse.common.pkgs.PkgDataSource
import eu.darken.sdmse.common.pkgs.container.NormalPkg
import eu.darken.sdmse.common.pkgs.features.Installed
import eu.darken.sdmse.common.pkgs.features.InstallerInfo
import eu.darken.sdmse.common.pkgs.pkgops.IllegalPkgDataException
import eu.darken.sdmse.common.pkgs.pkgops.PkgOps
import eu.darken.sdmse.common.root.RootManager
import eu.darken.sdmse.common.root.canUseRootNow
Expand Down Expand Up @@ -76,10 +76,10 @@ class NormalPkgsSource @Inject constructor(
// FYI: MATCH_ALL does not include MATCH_UNINSTALLED_PACKAGES
val pkgInfos = pkgOps.queryPkgs(PackageManager.MATCH_ALL)
if (pkgInfos.isEmpty()) {
throw IllegalPkgDataException("No installed packages")
throw InvalidPkgInventoryException("Could not retrieve list of installed packages")
}
if (pkgInfos.none { it.packageName == BuildConfigWrap.APPLICATION_ID }) {
throw IllegalPkgDataException("Returned package data didn't contain us")
throw InvalidPkgInventoryException("Returned package data didn't contain us")
}

val currentHandle = userManager.currentUser().handle
Expand Down
4 changes: 4 additions & 0 deletions app-common-pkgs/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<resources>
<string name="pkgrepo_invalid_inventory_error_title">Invalid app inventory</string>
<string name="pkgrepo_invalid_inventory_error_description">The system provided an invalid list of installed apps to SD Maid.</string>
</resources>
29 changes: 17 additions & 12 deletions app-common/src/main/java/eu/darken/sdmse/common/WebpageTool.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@ class WebpageTool @Inject constructor(
) {

fun open(address: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
log(ERROR) { "Failed to launch. No compatible activity!" }
} catch (e: SecurityException) {
// Permission Denial: starting Intent { act=android.intent.action.VIEW dat=https://github.com/...
// flg=0x10000000 cmp=com.mxtech.videoplayer.pro/com.mxtech.videoplayer.ActivityWebBrowser }
log(ERROR) { "Failed to launch activity due to $e" }
}
open(context, address)
}

companion object {
fun open(context: Context, address: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
log(ERROR) { "Failed to launch. No compatible activity!" }
} catch (e: SecurityException) {
// Permission Denial: starting Intent { act=android.intent.action.VIEW dat=https://github.com/...
// flg=0x10000000 cmp=com.mxtech.videoplayer.pro/com.mxtech.videoplayer.ActivityWebBrowser }
log(ERROR) { "Failed to launch activity due to $e" }
}
}
}
}
Loading

0 comments on commit cd03126

Please sign in to comment.