Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 77 additions & 6 deletions app/src/main/java/com/raofflineproxy/proxy/SmartCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Expand Down Expand Up @@ -180,7 +186,11 @@ internal data class SmartCacheCandidate(
internal data class SmartCacheStrategyResult(
val candidates: List<SmartCacheCandidate> = 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<String> = emptyList()
)

internal data class SmartCacheRunResult(
Expand All @@ -191,7 +201,11 @@ internal data class SmartCacheRunResult(
val needsSafGrant: Boolean = false,
val message: String? = null,
val requiredRomGrantPaths: List<String> = emptyList(),
val requiredSafGrantTargets: List<SmartCacheEmulator> = emptyList()
val requiredSafGrantTargets: List<SmartCacheEmulator> = 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<String> = emptyList()
)

private data class ResolvedSmartCacheCandidate(
Expand Down Expand Up @@ -581,14 +595,30 @@ private object Armsx2SmartCacheStrategy : SmartCacheStrategy {

private fun discoverArmsxCandidates(context: Context, emulator: SmartCacheEmulator, authorities: List<String>): 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")
Expand All @@ -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<SmartCacheCandidate>? {
private data class ArmsxRomLibraryResult(
val packageName: String,
val candidates: List<SmartCacheCandidate> = 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)
Expand All @@ -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)
Expand All @@ -652,6 +701,10 @@ internal suspend fun runSmartCache(
dolphinTreeUri: Uri?,
ppssppTreeUri: Uri?,
romTreeUris: List<Uri>,
/** 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<String> = emptySet(),
onProgress: (current: Int, total: Int, label: String) -> Unit
): SmartCacheRunResult {
Log.i(
Expand Down Expand Up @@ -685,6 +738,7 @@ internal suspend fun runSmartCache(
}

val requiredSafGrantTargets = mutableSetOf<SmartCacheEmulator>()
val requiredConsentPackages = linkedSetOf<String>()

val discoveredCandidates = linkedMapOf<String, SmartCacheCandidate>()
var needsSafGrant = false
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions app/src/main/java/com/raofflineproxy/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>()

// 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)
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -739,6 +769,27 @@ class MainActivity : AppCompatActivity() {
}
}

private fun requestCompanionLibraryConsent(packages: List<String>) {
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()))
}
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) : MainUiEvent
}

private sealed interface PendingCredentialAction {
Expand Down Expand Up @@ -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<String>()
private var pendingSmartCacheRomGrantPaths = emptyList<String>()
private var pendingSmartCacheGrantTargets = emptyList<SafGrantTarget>()
private var pendingPpssppShizukuRootModePrompt = false
Expand Down Expand Up @@ -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<Application>()
smartCacheJob = viewModelScope.launch {
Log.i("RAProxy/SmartCache", "startSmartCache invoked cachedGames=${_state.value.cachedGames.size}")
Expand All @@ -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)
Expand All @@ -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 {
Expand Down