From 9231ef9f5c545873141fcae7f48b1eb530f70a74 Mon Sep 17 00:00:00 2001 From: misantronic Date: Sun, 23 Aug 2026 16:03:58 +0200 Subject: [PATCH] Request companion library consent before smart caching --- .../com/raofflineproxy/proxy/SmartCache.kt | 83 +++++++++++++++++-- .../com/raofflineproxy/ui/MainActivity.kt | 51 ++++++++++++ .../com/raofflineproxy/ui/MainViewModel.kt | 27 +++++- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/raofflineproxy/proxy/SmartCache.kt b/app/src/main/java/com/raofflineproxy/proxy/SmartCache.kt index 1f3130c6..67feefe5 100644 --- a/app/src/main/java/com/raofflineproxy/proxy/SmartCache.kt +++ b/app/src/main/java/com/raofflineproxy/proxy/SmartCache.kt @@ -33,6 +33,12 @@ private const val RETROARCH_RECENT_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 private const val WATERMELONDS_RECENT_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 private const val ARMSX_RECENT_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 +// Cursor extra ARMSX sets when the caller has no grant. Must match +// RecentGamesContentProvider.EXTRA_ACCESS_DENIED upstream. +private const val ARMSX_EXTRA_ACCESS_DENIED = "com.armsx2.extra.RECENT_GAMES_ACCESS_DENIED" +private const val ARMSX_EXTRA_CONSENT_DECLINED = "com.armsx2.extra.RECENT_GAMES_CONSENT_DECLINED" +internal const val ARMSX_CONSENT_ACTION_SUFFIX = ".action.REQUEST_RECENT_GAMES_ACCESS" + private val WATERMELONDS_ROM_LIBRARY_AUTHORITIES by lazy { Emulator.WatermelonDs.packageCandidates.map { packageName -> "$packageName.romlibrary" } } @@ -180,7 +186,11 @@ internal data class SmartCacheCandidate( internal data class SmartCacheStrategyResult( val candidates: List = emptyList(), val message: String? = null, - val needsSafGrant: Boolean = false + val needsSafGrant: Boolean = false, + /** Packages whose library provider answered "you may not read this". Distinct from an empty + * result: the emulator has games, it just has not been allowed to share them yet, so the + * fix is to ask the user rather than to report that nothing was played. */ + val consentPackages: List = emptyList() ) internal data class SmartCacheRunResult( @@ -191,7 +201,11 @@ internal data class SmartCacheRunResult( val needsSafGrant: Boolean = false, val message: String? = null, val requiredRomGrantPaths: List = emptyList(), - val requiredSafGrantTargets: List = emptyList() + val requiredSafGrantTargets: List = emptyList(), + /** Emulator packages that have a library provider but have not allowed us to read it. The + * UI turns these into a consent request, which is the only way the user finds out that a + * supported emulator is sitting there contributing nothing. */ + val requiredConsentPackages: List = emptyList() ) private data class ResolvedSmartCacheCandidate( @@ -581,14 +595,30 @@ private object Armsx2SmartCacheStrategy : SmartCacheStrategy { private fun discoverArmsxCandidates(context: Context, emulator: SmartCacheEmulator, authorities: List): SmartCacheStrategyResult { val cutoff = System.currentTimeMillis() - ARMSX_RECENT_WINDOW_MS - val candidates = authorities.firstNotNullOfOrNull { authority -> + val reachable = authorities.firstNotNullOfOrNull { authority -> queryArmsxRomLibrary(context, authority, emulator, cutoff) } - if (candidates == null) { + if (reachable == null) { Log.i(TAG, "$emulator strategy did not find a readable rom library provider") return SmartCacheStrategyResult(message = "no_recent_games") } + if (reachable.accessDenied) { + // The emulator tracks whether this user already refused us, and forgets that the moment + // they touch its sharing switch. Trusting it beats remembering the refusal here, which + // would go stale the first time they change their mind without us running in between. + if (reachable.consentDeclined) { + Log.i(TAG, "$emulator strategy sharing declined by the user; not asking again") + return SmartCacheStrategyResult(message = "no_recent_games") + } + Log.i(TAG, "$emulator strategy needs sharing consent from ${reachable.packageName}") + return SmartCacheStrategyResult( + message = "needs_companion_consent", + consentPackages = listOf(reachable.packageName) + ) + } + + val candidates = reachable.candidates if (candidates.isEmpty()) { Log.i(TAG, "$emulator strategy found no recently played games within the last ${ARMSX_RECENT_WINDOW_MS / (24L * 60 * 60 * 1000)} days") return SmartCacheStrategyResult(message = "no_recent_games") @@ -598,20 +628,38 @@ private fun discoverArmsxCandidates(context: Context, emulator: SmartCacheEmulat return SmartCacheStrategyResult(candidates = candidates) } -private fun queryArmsxRomLibrary(context: Context, authority: String, emulator: SmartCacheEmulator, cutoff: Long): List? { +private data class ArmsxRomLibraryResult( + val packageName: String, + val candidates: List = emptyList(), + val accessDenied: Boolean = false, + val consentDeclined: Boolean = false +) + +private fun queryArmsxRomLibrary(context: Context, authority: String, emulator: SmartCacheEmulator, cutoff: Long): ArmsxRomLibraryResult? { val uri = Uri.Builder() .scheme("content") .authority(authority) .appendPath("games") .build() + val packageName = authority.removeSuffix(".romlibrary") return runCatching { context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + // Sharing off and "nothing played" are both an empty cursor; only this extra tells + // them apart, so an older build without it just looks like an empty library. + if (cursor.extras.getBoolean(ARMSX_EXTRA_ACCESS_DENIED, false)) { + return@use ArmsxRomLibraryResult( + packageName = packageName, + accessDenied = true, + consentDeclined = cursor.extras.getBoolean(ARMSX_EXTRA_CONSENT_DECLINED, false) + ) + } + val titleIndex = cursor.getColumnIndex("title") val uriIndex = cursor.getColumnIndex("uri") val lastPlayedIndex = cursor.getColumnIndex("lastPlayed") - buildList { + val candidates = buildList { while (cursor.moveToNext()) { val lastPlayed = if (lastPlayedIndex >= 0 && !cursor.isNull(lastPlayedIndex)) { cursor.getLong(lastPlayedIndex) @@ -636,6 +684,7 @@ private fun queryArmsxRomLibrary(context: Context, authority: String, emulator: ) } } + ArmsxRomLibraryResult(packageName = packageName, candidates = candidates) } }.onFailure { error -> Log.i(TAG, "$emulator strategy could not query authority=$authority", error) @@ -652,6 +701,10 @@ internal suspend fun runSmartCache( dolphinTreeUri: Uri?, ppssppTreeUri: Uri?, romTreeUris: List, + /** Companion packages we have already prompted for this round. Asking again would loop + * forever the moment someone taps Deny, so a package that has been put to the user once + * is treated as answered and the run proceeds without it. */ + consentAlreadyRequested: Set = emptySet(), onProgress: (current: Int, total: Int, label: String) -> Unit ): SmartCacheRunResult { Log.i( @@ -685,6 +738,7 @@ internal suspend fun runSmartCache( } val requiredSafGrantTargets = mutableSetOf() + val requiredConsentPackages = linkedSetOf() val discoveredCandidates = linkedMapOf() var needsSafGrant = false @@ -710,11 +764,28 @@ internal suspend fun runSmartCache( if (strategyMessage == null && !result.message.isNullOrBlank()) { strategyMessage = result.message } + requiredConsentPackages += result.consentPackages result.candidates.forEach { candidate -> discoveredCandidates.putIfAbsent(candidate.path, candidate) } } + val unaskedConsentPackages = requiredConsentPackages - consentAlreadyRequested + if (unaskedConsentPackages.isNotEmpty()) { + // Stop before caching, exactly as the SAF path does. Caching first and asking afterwards + // means the user answers a prompt about data the run has already finished without, and + // then sits through a second full pass to actually use it. + Log.i(TAG, "runSmartCache stopping before caching to request companion consent=$unaskedConsentPackages") + return SmartCacheRunResult( + matched = 0, + total = 0, + skipped = 0, + limitReached = false, + message = "needs_companion_consent", + requiredConsentPackages = unaskedConsentPackages.toList() + ) + } + if (needsSafGrant) { Log.i( TAG, diff --git a/app/src/main/java/com/raofflineproxy/ui/MainActivity.kt b/app/src/main/java/com/raofflineproxy/ui/MainActivity.kt index 869495bf..b0756183 100644 --- a/app/src/main/java/com/raofflineproxy/ui/MainActivity.kt +++ b/app/src/main/java/com/raofflineproxy/ui/MainActivity.kt @@ -9,6 +9,7 @@ import android.os.Bundle import android.provider.Settings import android.provider.DocumentsContract import android.util.Log +import com.raofflineproxy.proxy.ARMSX_CONSENT_ACTION_SUFFIX import android.view.Menu import android.view.MenuItem import android.view.LayoutInflater @@ -71,6 +72,33 @@ class MainActivity : AppCompatActivity() { } } + // Emulator packages still to ask for library-sharing consent, one prompt at a time: each + // emulator owns its own grant, and stacking dialogs from several apps at once is hostile. + private val pendingConsentPackages = mutableListOf() + + // Which package the in-flight prompt belongs to: the result callback has no other way to + // know whose answer it is carrying. + private var lastConsentRequestPackage: String? = null + + // StartActivityForResult, NOT a plain startActivity: the consent screen identifies us with + // getCallingPackage(), which is only populated for a for-result launch. Started any other way + // it cannot tell who is asking and refuses outright. + private val companionConsentLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + // The emulator records the outcome itself; nothing to persist on this side. + Log.i( + "RAProxy/SmartCache", + "companion library consent ${if (result.resultCode == RESULT_OK) "granted" else "declined"} for $lastConsentRequestPackage" + ) + if (pendingConsentPackages.isNotEmpty()) { + launchNextCompanionConsent() + } else { + // Not user-initiated: keeps the already-asked set, so a Deny resumes the run + // without the prompt instead of re-opening it forever. + viewModel.startSmartCache(userInitiated = false) + } + } + private val safLauncher = registerForActivityResult(OpenAndroidDataTree()) { uri -> if (uri == null) { viewModel.onSafRejected(SafGrantTarget.RetroArch) @@ -279,6 +307,8 @@ class MainActivity : AppCompatActivity() { MainUiEvent.OpenShizukuGuide -> openUrl(getString(R.string.manual_patching_shizuku_guide_url)) MainUiEvent.RequestShizukuPermission -> Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) is MainUiEvent.ShowAppUpdate -> showAppUpdateDialog(event.update) + is MainUiEvent.RequestCompanionLibraryConsent -> + requestCompanionLibraryConsent(event.packages) } } } @@ -739,6 +769,27 @@ class MainActivity : AppCompatActivity() { } } + private fun requestCompanionLibraryConsent(packages: List) { + pendingConsentPackages.clear() + pendingConsentPackages.addAll(packages) + launchNextCompanionConsent() + } + + private fun launchNextCompanionConsent() { + if (pendingConsentPackages.isEmpty()) return + val target = pendingConsentPackages.removeAt(0) + lastConsentRequestPackage = target + val intent = Intent(target + ARMSX_CONSENT_ACTION_SUFFIX) + intent.setPackage(target) + val launched = runCatching { companionConsentLauncher.launch(intent) }.isSuccess + if (!launched) { + // Older build without the consent screen. Nothing to ask, so carry on rather than + // stalling the whole smart-cache run on one emulator. + Log.i("RAProxy/SmartCache", "no consent activity for $target") + if (pendingConsentPackages.isNotEmpty()) launchNextCompanionConsent() else viewModel.startSmartCache(userInitiated = false) + } + } + private fun openUrl(url: String) { startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } diff --git a/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt b/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt index 57f2b874..c3adc742 100644 --- a/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt +++ b/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt @@ -108,6 +108,9 @@ sealed interface MainUiEvent { data class ShowAppUpdate(val update: AppUpdateInfo) : MainUiEvent data object RequestShizukuPermission : MainUiEvent data object PromptPpssppShizukuRootMode : MainUiEvent + /** A companion emulator has a library provider but has not been allowed to share it. + * Carries the emulator packages to ask, in order. */ + data class RequestCompanionLibraryConsent(val packages: List) : MainUiEvent } private sealed interface PendingCredentialAction { @@ -185,6 +188,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private var pendingProxyStart = false private var pendingSmartCacheStart = false + + // Companion packages already put to the user. Cleared when the user starts smart caching + // themselves, so a manual retry asks again, but NOT on the automatic re-run that follows a + // prompt — that is what would otherwise spin forever on Deny. + private val consentRequestedPackages = mutableSetOf() private var pendingSmartCacheRomGrantPaths = emptyList() private var pendingSmartCacheGrantTargets = emptyList() private var pendingPpssppShizukuRootModePrompt = false @@ -1591,7 +1599,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { smartCacheJob?.cancel() } - fun startSmartCache() { + fun startSmartCache(userInitiated: Boolean = true) { + if (userInitiated) { + consentRequestedPackages.clear() + } val app = getApplication() smartCacheJob = viewModelScope.launch { Log.i("RAProxy/SmartCache", "startSmartCache invoked cachedGames=${_state.value.cachedGames.size}") @@ -1618,7 +1629,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { retroArchTreeUri = loadSafUri(), dolphinTreeUri = loadDolphinSafUri(), ppssppTreeUri = loadPpssppSafUri(), - romTreeUris = romTreeUris + romTreeUris = romTreeUris, + consentAlreadyRequested = consentRequestedPackages.toSet() ) { current, total, label -> val progressMessage = str(R.string.smart_cache_progress, current, total, label) _state.value = _state.value.copy(scanProgress = progressMessage) @@ -1629,6 +1641,17 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { "RAProxy/SmartCache", "startSmartCache result matched=${result.matched} total=${result.total} skipped=${result.skipped} limitReached=${result.limitReached} needsSafGrant=${result.needsSafGrant} message=${result.message}" ) + if (result.requiredConsentPackages.isNotEmpty()) { + consentRequestedPackages += result.requiredConsentPackages + // Ask before reporting a thin result: the emulator is installed and has + // games, it simply has not been allowed to share them, and the user has no + // other way to find that out. + pendingSmartCacheStart = true + _events.tryEmit( + MainUiEvent.RequestCompanionLibraryConsent(result.requiredConsentPackages) + ) + return@launch + } if (result.needsSafGrant) { pendingSmartCacheStart = true val safTargets = buildList {