diff --git a/app/src/main/java/com/raofflineproxy/data/CacheDao.kt b/app/src/main/java/com/raofflineproxy/data/CacheDao.kt index cb38db18..4668b9b8 100644 --- a/app/src/main/java/com/raofflineproxy/data/CacheDao.kt +++ b/app/src/main/java/com/raofflineproxy/data/CacheDao.kt @@ -49,6 +49,8 @@ interface CacheDao { AND cacheKey NOT LIKE 'startsession:%' AND cacheKey NOT LIKE 'gameid:%' AND cacheKey NOT LIKE 'cachequeue:%' + AND cacheKey NOT LIKE 'watchfolder:%' + AND cacheKey NOT LIKE 'watchseen:%' """ ) suspend fun evictOlderThan(before: Long) diff --git a/app/src/main/java/com/raofflineproxy/data/CacheKeys.kt b/app/src/main/java/com/raofflineproxy/data/CacheKeys.kt index 30cf01cb..e4245073 100644 --- a/app/src/main/java/com/raofflineproxy/data/CacheKeys.kt +++ b/app/src/main/java/com/raofflineproxy/data/CacheKeys.kt @@ -11,6 +11,8 @@ object CacheKeys { const val PREFIX_GAMEID = "gameid:" const val PREFIX_LAST_PLAYED = "lastplayed:" const val PREFIX_CACHE_QUEUE = "cachequeue:" + const val PREFIX_WATCH_FOLDER = "watchfolder:" + const val PREFIX_WATCH_SEEN = "watchseen:" const val CACHE_BUDGET = "cachebudget" fun login(user: String) = "$PREFIX_LOGIN${user.lowercase()}" @@ -26,6 +28,9 @@ object CacheKeys { fun startSession(gameId: String, user: String) = "$PREFIX_STARTSESSION$gameId:${user.lowercase()}:0" fun lastPlayed(gameId: Int) = "$PREFIX_LAST_PLAYED$gameId" fun cacheQueue(hash: String) = "$PREFIX_CACHE_QUEUE${hash.lowercase()}" + fun watchFolder(treeUri: String) = "$PREFIX_WATCH_FOLDER$treeUri" + fun watchSeenPrefix(treeUri: String) = "$PREFIX_WATCH_SEEN$treeUri|" + fun watchSeen(treeUri: String, romPath: String) = "${watchSeenPrefix(treeUri)}$romPath" fun parseGameIdFromPatchKey(cacheKey: String): Int? = cacheKey.removePrefix(PREFIX_PATCH).split(":").firstOrNull()?.toIntOrNull() diff --git a/app/src/main/java/com/raofflineproxy/proxy/RomScanner.kt b/app/src/main/java/com/raofflineproxy/proxy/RomScanner.kt index b2739742..2c3fee5d 100644 --- a/app/src/main/java/com/raofflineproxy/proxy/RomScanner.kt +++ b/app/src/main/java/com/raofflineproxy/proxy/RomScanner.kt @@ -40,7 +40,7 @@ private const val HTTP_RETRY_AFTER_HEADER = "Retry-After" private const val HTTP_GET_MAX_429_RETRIES = 4 private const val HTTP_GET_INITIAL_429_BACKOFF_MS = 2_000L private const val HTTP_GET_MAX_429_BACKOFF_MS = 15_000L -private const val MAX_SCAN_ENTRIES = 5000 +internal const val MAX_SCAN_ENTRIES = 5000 private const val MAX_SCAN_DEPTH = 12 // RetroAchievements adds hashes over time, so a "no match" is only cached long enough to // stop a repeated scan of the same folder from re-querying every unsupported ROM. @@ -699,7 +699,7 @@ internal fun String.normalizeCachedRomPath(): String = replace('\\', '/') .trim() -private fun collectScannableFiles(root: DocumentFile, maxEntries: Int): List { +internal fun collectScannableFiles(root: DocumentFile, maxEntries: Int): List { val result = mutableListOf() if (maxEntries <= 0) return result val stack = ArrayDeque>() diff --git a/app/src/main/java/com/raofflineproxy/proxy/WatchFolders.kt b/app/src/main/java/com/raofflineproxy/proxy/WatchFolders.kt new file mode 100644 index 00000000..60178003 --- /dev/null +++ b/app/src/main/java/com/raofflineproxy/proxy/WatchFolders.kt @@ -0,0 +1,189 @@ +package com.raofflineproxy.proxy + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.raofflineproxy.data.AppDatabase +import com.raofflineproxy.data.CacheEntry +import com.raofflineproxy.data.CacheKeys +import com.raofflineproxy.data.forEachEntryByPrefix +import com.raofflineproxy.proxy.hash.hashRomCandidates +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.json.JSONObject + +private const val TAG = "RAProxy/WatchFolders" + +internal const val WATCH_RESCAN_INTERVAL_MS = CACHE_BUDGET_WINDOW_MS + +data class WatchedFolder( + val uri: String, + val name: String, + val path: String?, + val watching: Boolean, + val addedAt: Long, + val lastScanAt: Long +) { + // A clock that jumped backwards makes the folder due again instead of postponing it. + fun isDue(now: Long): Boolean = + watching && (now < lastScanAt || now - lastScanAt >= WATCH_RESCAN_INTERVAL_MS) + + fun toJson(): String = JSONObject() + .put("uri", uri) + .put("name", name) + .put("path", path ?: JSONObject.NULL) + .put("watching", watching) + .put("addedAt", addedAt) + .put("lastScanAt", lastScanAt) + .toString() + + companion object { + fun fromJson(body: String): WatchedFolder? = runCatching { + val json = JSONObject(body) + WatchedFolder( + uri = json.getString("uri").takeIf { it.isNotBlank() } ?: return null, + name = json.optString("name"), + path = json.optString("path").takeIf { !json.isNull("path") && it.isNotBlank() }, + watching = json.optBoolean("watching", false), + addedAt = json.optLong("addedAt"), + lastScanAt = json.optLong("lastScanAt") + ) + }.getOrNull() + } +} + +internal data class FolderScan(val files: Int, val new: Int, val handled: Int, val queued: Int) { + val complete: Boolean get() = handled == new +} + +/** Folders the user has added, stored in the cache table so that no schema change is needed. + * A watched folder is rescanned once per budget window; [scanWatchedFolder] hashes and queues + * only the files it has not handled before. */ +internal object WatchFolders { + fun observe(db: AppDatabase): Flow> = + db.cacheDao().observeByPrefix(CacheKeys.PREFIX_WATCH_FOLDER).map { entries -> + entries.mapNotNull { WatchedFolder.fromJson(it.responseBody) } + .sortedBy { it.name.lowercase() } + } + + suspend fun all(db: AppDatabase): List = buildList { + db.cacheDao().forEachEntryByPrefix(CacheKeys.PREFIX_WATCH_FOLDER) { entry -> + WatchedFolder.fromJson(entry.responseBody)?.let(::add) + } + } + + suspend fun due(db: AppDatabase, now: Long): List = + all(db).filter { it.isDue(now) } + + /** Returns false when the folder was already known; its watching state stays as it was. */ + suspend fun add(context: Context, db: AppDatabase, treeUri: Uri, now: Long = System.currentTimeMillis()): Boolean { + val root = DocumentFile.fromTreeUri(context, treeUri) + val folder = WatchedFolder( + uri = treeUri.toString(), + name = root?.name ?: treeUri.lastPathSegment?.substringAfterLast(':').orEmpty(), + path = root?.let(::resolveDocumentRelativePath), + watching = false, + addedAt = now, + lastScanAt = 0L + ) + return db.cacheDao().insertIgnore( + CacheEntry(cacheKey = CacheKeys.watchFolder(folder.uri), responseBody = folder.toJson()) + ) != -1L + } + + suspend fun setWatching(db: AppDatabase, uri: String, watching: Boolean) { + update(db, uri) { it.copy(watching = watching, lastScanAt = 0L) } + } + + suspend fun remove(db: AppDatabase, uri: String) { + db.cacheDao().deleteByKey(CacheKeys.watchFolder(uri)) + forgetSeen(db, seenKeys(db, uri)) + } + + suspend fun recordScan(db: AppDatabase, uri: String, scannedAt: Long) { + update(db, uri) { it.copy(lastScanAt = scannedAt) } + } + + private suspend fun update(db: AppDatabase, uri: String, change: (WatchedFolder) -> WatchedFolder) { + val key = CacheKeys.watchFolder(uri) + val current = db.cacheDao().get(key)?.let { WatchedFolder.fromJson(it.responseBody) } ?: return + db.cacheDao().updateBody(key, change(current).toJson(), null, System.currentTimeMillis()) + } + + // The prefix lookup is a LIKE match, so "_" in a tree uri would also match neighbouring + // folders; the startsWith check keeps it to this one. + private suspend fun seenKeys(db: AppDatabase, uri: String): Set { + val prefix = CacheKeys.watchSeenPrefix(uri) + return db.cacheDao().getKeysByPrefix(prefix).filterTo(mutableSetOf()) { it.startsWith(prefix) } + } + + private suspend fun seenPaths(db: AppDatabase, uri: String): Set { + val prefixLength = CacheKeys.watchSeenPrefix(uri).length + return seenKeys(db, uri).mapTo(mutableSetOf()) { it.substring(prefixLength) } + } + + private suspend fun markSeen(db: AppDatabase, uri: String, path: String) { + db.cacheDao().insertIgnore(CacheEntry(cacheKey = CacheKeys.watchSeen(uri, path), responseBody = "{}")) + } + + private suspend fun forgetSeen(db: AppDatabase, keys: Collection) { + keys.forEach { db.cacheDao().deleteByKey(it) } + } + + /** Hashes and queues only the files this folder has not handled before. A file counts as + * handled once caching has an answer for it, including "no RetroAchievements match", so + * unknown files are not hashed again on every pass. Returns null when the folder cannot + * be read, e.g. because its access grant is gone. */ + suspend fun scan( + context: Context, + db: AppDatabase, + folder: WatchedFolder, + shouldAbort: () -> Boolean, + now: () -> Long = System::currentTimeMillis + ): FolderScan? { + val treeUri = folder.uri.toUri() + val granted = context.contentResolver.persistedUriPermissions.any { it.uri == treeUri && it.isReadPermission } + val root = DocumentFile.fromTreeUri(context, treeUri)?.takeIf { granted && it.canRead() } ?: return null + + val files = collectScannableFiles(root, MAX_SCAN_ENTRIES) + val romPaths = files.map(::resolveDocumentAbsolutePath) + val seenKeys = files.indices.map { romPaths[it] ?: files[it].uri.toString() } + val seen = seenPaths(db, folder.uri) + val knownPaths = loadCachedRomPaths(db) + CacheQueue.queuedRomPaths(db) + val cachedGameIds = loadCachedGameIds(db) + + val unseen = files.indices.filter { seenKeys[it] !in seen } + val (known, fresh) = unseen.partition { romPaths[it]?.normalizeCachedRomPath() in knownPaths } + known.forEach { markSeen(db, folder.uri, seenKeys[it]) } + + var handled = 0 + var queued = 0 + for (index in fresh) { + if (shouldAbort()) break + val file = files[index] + val candidates = runCatching { hashRomCandidates(context, file) } + .onFailure { Log.w(TAG, "Could not hash ${file.name}", it) } + .getOrDefault(emptyList()) + if (candidates.isNotEmpty() && + enqueueRomIfNeeded(db, candidates, cachedGameIds, romPaths[index], file.name ?: "") + ) { + queued++ + } + markSeen(db, folder.uri, seenKeys[index]) + handled++ + } + + // A capped listing leaves out files that still exist, so their markers must survive. + if (files.size < MAX_SCAN_ENTRIES) { + val prefix = CacheKeys.watchSeenPrefix(folder.uri) + val gone = (seen - seenKeys.toSet()).map { "$prefix$it" } + forgetSeen(db, gone) + } + + val scan = FolderScan(files.size, fresh.size, handled, queued) + if (scan.complete) recordScan(db, folder.uri, now()) + return scan + } +} diff --git a/app/src/main/java/com/raofflineproxy/service/CacheQueueAlarms.kt b/app/src/main/java/com/raofflineproxy/service/CacheQueueAlarms.kt index 3e024840..fe882cb4 100644 --- a/app/src/main/java/com/raofflineproxy/service/CacheQueueAlarms.kt +++ b/app/src/main/java/com/raofflineproxy/service/CacheQueueAlarms.kt @@ -59,6 +59,9 @@ internal val CacheQueueWakeLock = CachingWakeLock("RAOfflineProxy:cacheQueue") * apart from the worker's lock, whose release at the end of every round would otherwise end it. */ internal val BulkRunWakeLock = CachingWakeLock("RAOfflineProxy:bulkRun") +/** Hashing a watched folder in the service; kept apart from the worker's lock for the same reason. */ +internal val FolderWatchWakeLock = CachingWakeLock("RAOfflineProxy:folderWatch") + class CacheQueueAlarmReceiver : BroadcastReceiver() { // The system only holds a wake lock for onReceive, so take ours here: signalling the worker // alone could let the CPU fall asleep again before it runs. diff --git a/app/src/main/java/com/raofflineproxy/service/ProxyService.kt b/app/src/main/java/com/raofflineproxy/service/ProxyService.kt index e2cc8e5b..00b68e75 100644 --- a/app/src/main/java/com/raofflineproxy/service/ProxyService.kt +++ b/app/src/main/java/com/raofflineproxy/service/ProxyService.kt @@ -34,6 +34,7 @@ import com.raofflineproxy.data.CacheKeys import com.raofflineproxy.proxyUserAgent import com.raofflineproxy.proxy.AwardFlusher import com.raofflineproxy.proxy.CacheQueue +import com.raofflineproxy.proxy.WatchFolders import com.raofflineproxy.proxy.RateLimitBackoff import com.raofflineproxy.proxy.GameActivity import com.raofflineproxy.proxy.ProxyServer @@ -47,6 +48,7 @@ import com.raofflineproxy.proxy.DrainStop import com.raofflineproxy.update.AppUpdateNotifier import com.raofflineproxy.usage.UsageReporter import com.raofflineproxy.proxy.drainCacheQueue +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -89,6 +91,7 @@ class ProxyService : Service() { private var networkCallbackRegistered = false private var refreshJob: Job? = null private var cacheQueueJob: Job? = null + private var folderWatchJob: Job? = null private var cacheQueueRejectedToken: String? = null @Volatile private var queueLoginBlocked = false set(value) { @@ -198,6 +201,10 @@ class ProxyService : Service() { cacheQueueJob = serviceScope.launch { cacheQueueLoop() } } + if (folderWatchJob?.isActive != true) { + folderWatchJob = serviceScope.launch { folderWatchLoop() } + } + CachingNotifications.clearStandalone(this) if (cachingObserverJob?.isActive != true) { cachingObserverJob = serviceScope.launch { @@ -360,6 +367,48 @@ class ProxyService : Service() { return (resumeAt - System.currentTimeMillis()).coerceAtLeast(1_000L) } + private suspend fun folderWatchLoop() { + while (true) { + try { + watchFoldersOnce() + } catch (c: CancellationException) { + throw c + } catch (t: Throwable) { + Log.e(TAG, "Watched folder round failed", t) + } + withTimeoutOrNull(CACHE_QUEUE_POLL_MS.milliseconds) { folderWatchWake.receive() } + } + } + + /** Hashes and queues what watched folders gained since their last scan; the cache queue + * then caches it within the budget. Runs as a bulk run so the queue worker doesn't drain + * while it is still filling, and stands down whenever a game is being played. */ + private suspend fun watchFoldersOnce() { + val folders = WatchFolders.due(db, System.currentTimeMillis()) + if (folders.isEmpty() || !canWatchFolders()) return + val credentials = loadLoginCredentials(db) ?: return + if (credentials.token == cacheQueueRejectedToken) return + FolderWatchWakeLock.hold(this) + try { + CacheQueue.duringBulkRun { + for (folder in folders) { + if (!canWatchFolders(ignoreBulkRun = true)) break + val scan = WatchFolders.scan(this, db, folder, shouldAbort = { !canWatchFolders(ignoreBulkRun = true) }) + Log.i( + TAG, + "Watched folder ${folder.name}: " + (scan?.let { "${it.files} file(s), ${it.new} new, ${it.queued} queued" } ?: "unreadable") + ) + } + } + } finally { + FolderWatchWakeLock.release() + wakeCacheQueue() + } + } + + private fun canWatchFolders(ignoreBulkRun: Boolean = false): Boolean = + (ignoreBulkRun || !CacheQueue.bulkRunActive) && isServerReachable() && onlineRefreshIdleDelayMs() <= 0 + private fun canWorkOnCacheQueue(): Boolean = !CacheQueue.bulkRunActive && isServerReachable() && onlineRefreshIdleDelayMs() <= 0 @@ -442,6 +491,7 @@ class ProxyService : Service() { } CacheQueueAlarm.cancel(this) CacheQueueWakeLock.release() + FolderWatchWakeLock.release() proxyServer.stop() if (networkCallbackRegistered) { runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) } @@ -679,6 +729,7 @@ class ProxyService : Service() { @Volatile private var runningInProcess = false private val cacheQueueWake = Channel(Channel.CONFLATED) + private val folderWatchWake = Channel(Channel.CONFLATED) private val runtimeLock = Any() private val _runtime = MutableStateFlow(ServiceRuntime()) @@ -695,6 +746,10 @@ class ProxyService : Service() { cacheQueueWake.trySend(Unit) } + fun wakeFolderWatch() { + folderWatchWake.trySend(Unit) + } + private fun restartPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( context, RESTART_REQUEST_CODE, diff --git a/app/src/main/java/com/raofflineproxy/ui/CachedGamesFragment.kt b/app/src/main/java/com/raofflineproxy/ui/CachedGamesFragment.kt index e75ba805..8c8828c4 100644 --- a/app/src/main/java/com/raofflineproxy/ui/CachedGamesFragment.kt +++ b/app/src/main/java/com/raofflineproxy/ui/CachedGamesFragment.kt @@ -46,6 +46,7 @@ class CachedGamesFragment : Fragment() { private var searchOpen = false private var searchQuery = "" private var queueDialog: AlertDialog? = null + private var foldersDialog: AlertDialog? = null private val romFolderPickerLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -56,6 +57,7 @@ class CachedGamesFragment : Fragment() { requireContext().contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) + viewModel.rememberFolder(uri) viewModel.scanRoms(uri) } @@ -113,6 +115,7 @@ class CachedGamesFragment : Fragment() { .also { it.setCanceledOnTouchOutside(false) } .show() }, + onFolders = ::showFoldersDialog, onSearchChanged = { query -> searchQuery = query publishHeaderState(headerState) @@ -141,6 +144,12 @@ class CachedGamesFragment : Fragment() { } } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.watchedFolders.collect { folders -> + publishHeaderState(headerState.copy(folderCount = folders.size)) + } + } + viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collect { state -> gamesAdapter?.showLocked = state.showLockedAchievements @@ -152,14 +161,11 @@ class CachedGamesFragment : Fragment() { val showSmartCache = !viewModel.isSmartCacheDisabledForShizuku(state) val smartCacheEnabled = actionsEnabled && showSmartCache val scanEnabled = state.isOnline && !state.scanInProgress - val statusText = when { - !state.isOnline -> getString(R.string.cached_games_offline_hint) - state.queuedRomCount == 0 -> getString(R.string.cached_games_counter, state.cachedGames.size) - !state.proxyRunning -> getString( - R.string.cached_games_counter_queued_paused, - state.cachedGames.size, - state.queuedRomCount - ) + val counter = getString(R.string.cached_games_counter, state.cachedGames.size) + val statusText = if (state.isOnline) counter else getString(R.string.cached_games_offline_hint) + val queueStatusText = when { + !state.isOnline || state.queuedRomCount == 0 -> null + !state.proxyRunning -> getString(R.string.cached_games_queued_paused, state.queuedRomCount) else -> queuedStatusText(state) } publishHeaderState( @@ -169,7 +175,9 @@ class CachedGamesFragment : Fragment() { scanEnabled = scanEnabled, clearEnabled = state.cachedGames.isNotEmpty() && !state.scanInProgress, showNoCachedGames = state.cachedGames.isEmpty(), - statusText = statusText + statusText = statusText, + folderCount = viewModel.watchedFolders.value.size, + queueStatusText = queueStatusText ) ) updateQueueDialog(state.pendingQueueConfirmation) @@ -181,6 +189,8 @@ class CachedGamesFragment : Fragment() { super.onDestroyView() queueDialog?.dismiss() queueDialog = null + foldersDialog?.dismiss() + foldersDialog = null gamesAdapter = null headerAdapter = null } @@ -194,6 +204,34 @@ class CachedGamesFragment : Fragment() { gamesAdapter?.submitList(buildGroupedList(currentGames, collapsedConsoleIds, searchQuery)) } + private fun showFoldersDialog() { + if (foldersDialog != null) return + val content = layoutInflater.inflate(R.layout.dialog_watch_folders, null) + val adapter = WatchFoldersAdapter( + onToggleWatching = { folder -> viewModel.setFolderWatching(folder, !folder.watching) }, + onRemove = viewModel::removeFolder + ) + content.findViewById(R.id.rv_watch_folders).apply { + layoutManager = LinearLayoutManager(requireContext()) + this.adapter = adapter + } + val collector = viewLifecycleOwner.lifecycleScope.launch { + viewModel.watchedFolders.collect { folders -> + adapter.submitList(folders) + if (folders.isEmpty()) foldersDialog?.dismiss() + } + } + foldersDialog = AlertDialog.Builder(requireContext()) + .setTitle(R.string.watch_folders_title) + .setView(content) + .setPositiveButton(R.string.watch_folders_close, null) + .setOnDismissListener { + collector.cancel() + foldersDialog = null + } + .show() + } + private fun updateQueueDialog(estimate: QueueEstimate?) { if (estimate == null) { queueDialog?.dismiss() @@ -255,18 +293,18 @@ class CachedGamesFragment : Fragment() { } private fun queuedStatusText(state: MainUiState): String { - val counter = getString(R.string.cached_games_counter_queued, state.cachedGames.size, state.queuedRomCount) + val queued = getString(R.string.cached_games_queued, state.queuedRomCount) val nextBatchAt = state.nextQueueBatchAt val batch = when { state.queueCachingNow -> getString(R.string.cached_games_queue_caching_now) - nextBatchAt == null -> return counter + nextBatchAt == null -> return queued state.nextQueueBatchDue -> getString(R.string.cached_games_queue_next_batch_soon) else -> getString( R.string.cached_games_queue_next_batch, DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(nextBatchAt)) ) } - return getString(R.string.cached_games_counter_with_queue_status, counter, batch) + return getString(R.string.cached_games_counter_with_queue_status, queued, batch) } private fun guessRomFolderInitialUri() = diff --git a/app/src/main/java/com/raofflineproxy/ui/CachedGamesHeaderAdapter.kt b/app/src/main/java/com/raofflineproxy/ui/CachedGamesHeaderAdapter.kt index c7a7c40a..8f417b9a 100644 --- a/app/src/main/java/com/raofflineproxy/ui/CachedGamesHeaderAdapter.kt +++ b/app/src/main/java/com/raofflineproxy/ui/CachedGamesHeaderAdapter.kt @@ -3,7 +3,12 @@ package com.raofflineproxy.ui import android.view.LayoutInflater import android.view.View import android.text.Editable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint import android.text.TextWatcher +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager @@ -11,12 +16,14 @@ import android.widget.EditText import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton +import com.google.android.material.color.MaterialColors import com.raofflineproxy.R class CachedGamesHeaderAdapter( private val onSmartCache: () -> Unit, private val onScan: () -> Unit, private val onClear: () -> Unit, + private val onFolders: () -> Unit, private val onSearchChanged: (String) -> Unit, private val onSearchToggled: (Boolean) -> Unit ) : RecyclerView.Adapter() { @@ -30,6 +37,8 @@ class CachedGamesHeaderAdapter( val clearEnabled: Boolean = true, val showNoCachedGames: Boolean = false, val statusText: String? = null, + val folderCount: Int = 0, + val queueStatusText: String? = null, val searchOpen: Boolean = false, val searchQuery: String = "" ) @@ -81,6 +90,27 @@ class CachedGamesHeaderAdapter( if (!s.searchOpen && wasOpen) hideKeyboard(etSearch) } + private fun statusWithFolders(s: HeaderState): CharSequence? { + val status = s.statusText ?: return null + val context = tvScanHint.context + val text = SpannableStringBuilder(status) + if (s.folderCount > 0) { + val label = context.resources.getQuantityString(R.plurals.cached_games_folders, s.folderCount, s.folderCount) + text.append(" · ") + val start = text.length + text.append(label) + text.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) = onFolders() + override fun updateDrawState(ds: TextPaint) { + ds.color = MaterialColors.getColor(tvScanHint, androidx.appcompat.R.attr.colorPrimary) + ds.isUnderlineText = true + } + }, start, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + s.queueStatusText?.let { text.append(" · ").append(it) } + return text + } + fun bind(s: HeaderState) { bindSearch(s) btnSmartCache.visibility = if (s.showSmartCache) View.VISIBLE else View.GONE @@ -93,7 +123,8 @@ class CachedGamesHeaderAdapter( btnClear.isEnabled = s.clearEnabled btnClear.alpha = if (s.clearEnabled) 1f else 0.38f - tvScanHint.text = s.statusText + tvScanHint.text = statusWithFolders(s) + tvScanHint.movementMethod = LinkMovementMethod.getInstance() tvScanHint.visibility = if (s.statusText.isNullOrEmpty() || s.searchOpen) View.GONE else View.VISIBLE diff --git a/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt b/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt index c86957d9..421eb893 100644 --- a/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt +++ b/app/src/main/java/com/raofflineproxy/ui/MainViewModel.kt @@ -47,6 +47,8 @@ import com.raofflineproxy.service.text import com.raofflineproxy.proxy.CACHE_BUDGET_LIMIT import com.raofflineproxy.proxy.CacheQueue import com.raofflineproxy.proxy.QueueEstimate +import com.raofflineproxy.proxy.WatchFolders +import com.raofflineproxy.proxy.WatchedFolder import com.raofflineproxy.proxy.SmartCacheRunResult import com.raofflineproxy.proxy.ScanResult import com.raofflineproxy.proxy.drainCacheQueue @@ -98,9 +100,11 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap @@ -211,6 +215,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val events = _events.asSharedFlow() private val _cachedGames = MutableStateFlow>(emptyList()) val cachedGames: StateFlow> = _cachedGames.asStateFlow() + val watchedFolders: StateFlow> = + WatchFolders.observe(db).stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) private val pendingDeletedGameIds: MutableSet = ConcurrentHashMap.newKeySet() private val patchCache = mutableMapOf() private val unlockCache = mutableMapOf() @@ -1667,6 +1673,21 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { } } + fun rememberFolder(treeUri: Uri) { + viewModelScope.launch(Dispatchers.IO) { WatchFolders.add(application, db, treeUri) } + } + + fun setFolderWatching(folder: WatchedFolder, watching: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + WatchFolders.setWatching(db, folder.uri, watching) + if (watching) ProxyService.wakeFolderWatch() + } + } + + fun removeFolder(folder: WatchedFolder) { + viewModelScope.launch(Dispatchers.IO) { WatchFolders.remove(db, folder.uri) } + } + fun deleteCachedGame(game: CachedGame) { removeCachedGamesFromState(setOf(game.gameId)) viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/com/raofflineproxy/ui/WatchFoldersAdapter.kt b/app/src/main/java/com/raofflineproxy/ui/WatchFoldersAdapter.kt new file mode 100644 index 00000000..28ebbcea --- /dev/null +++ b/app/src/main/java/com/raofflineproxy/ui/WatchFoldersAdapter.kt @@ -0,0 +1,67 @@ +package com.raofflineproxy.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.color.MaterialColors +import com.raofflineproxy.R +import com.raofflineproxy.proxy.WatchedFolder +import java.text.DateFormat +import java.util.Date + +class WatchFoldersAdapter( + private val onToggleWatching: (WatchedFolder) -> Unit, + private val onRemove: (WatchedFolder) -> Unit +) : ListAdapter(Diff) { + + inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val name: TextView = view.findViewById(R.id.tv_folder_name) + private val path: TextView = view.findViewById(R.id.tv_folder_path) + private val status: TextView = view.findViewById(R.id.tv_folder_status) + private val watch: MaterialButton = view.findViewById(R.id.btn_folder_watch) + private val remove: MaterialButton = view.findViewById(R.id.btn_folder_remove) + + fun bind(folder: WatchedFolder) { + val context = itemView.context + name.text = folder.name.ifBlank { folder.uri } + path.text = folder.path + path.visibility = if (folder.path.isNullOrBlank()) View.GONE else View.VISIBLE + status.text = when { + !folder.watching -> context.getString(R.string.watch_folders_not_watching) + folder.lastScanAt == 0L -> context.getString(R.string.watch_folders_not_checked) + else -> context.getString( + R.string.watch_folders_last_checked, + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(folder.lastScanAt)) + ) + } + val label = context.getString(if (folder.watching) R.string.watch_folders_stop else R.string.watch_folders_start) + watch.setIconResource(if (folder.watching) R.drawable.ic_visibility else R.drawable.ic_visibility_off) + watch.iconTint = android.content.res.ColorStateList.valueOf( + MaterialColors.getColor( + watch, + if (folder.watching) androidx.appcompat.R.attr.colorPrimary else com.google.android.material.R.attr.colorOnSurface + ) + ) + watch.contentDescription = label + watch.tooltipText = label + watch.setOnClickListener { onToggleWatching(folder) } + remove.setOnClickListener { onRemove(folder) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.item_watch_folder, parent, false) + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(getItem(position)) + + private object Diff : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: WatchedFolder, new: WatchedFolder) = old.uri == new.uri + override fun areContentsTheSame(old: WatchedFolder, new: WatchedFolder) = old == new + } +} diff --git a/app/src/main/res/drawable/ic_visibility.xml b/app/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 00000000..898458e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 00000000..08588841 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/dialog_watch_folders.xml b/app/src/main/res/layout/dialog_watch_folders.xml new file mode 100644 index 00000000..1918a615 --- /dev/null +++ b/app/src/main/res/layout/dialog_watch_folders.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_watch_folder.xml b/app/src/main/res/layout/item_watch_folder.xml new file mode 100644 index 00000000..d60d26f1 --- /dev/null +++ b/app/src/main/res/layout/item_watch_folder.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76beb41a..30438cbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -329,12 +329,26 @@ Go online to add or scan games. %1$d cached - %1$d cached · %2$d queued + %1$d queued %1$s · %2$s next batch %1$s next batch soon caching now - %1$d cached · %2$d queued (paused while the proxy is stopped) + %1$d queued (paused while the proxy is stopped) + + %d folder + %d folders + + Folders + Watched folders are checked for new ROMs every 30 minutes while the proxy is running. New games are cached automatically. + No folders yet. Scan a ROM folder to add one. + Watch folder + Stop watching folder + Remove folder + Not checked yet + Last checked %1$s + Not watched + Close Cache in the background? diff --git a/app/src/test/java/com/raofflineproxy/WatchedFolderTest.kt b/app/src/test/java/com/raofflineproxy/WatchedFolderTest.kt new file mode 100644 index 00000000..63dbac02 --- /dev/null +++ b/app/src/test/java/com/raofflineproxy/WatchedFolderTest.kt @@ -0,0 +1,54 @@ +package com.raofflineproxy + +import com.raofflineproxy.data.CacheKeys +import com.raofflineproxy.proxy.WATCH_RESCAN_INTERVAL_MS +import com.raofflineproxy.proxy.WatchedFolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WatchedFolderTest { + private val folder = WatchedFolder( + uri = "content://tree/primary%3AROMs", + name = "ROMs", + path = "ROMs", + watching = true, + addedAt = 1_000L, + lastScanAt = 5_000L + ) + + @Test + fun json_roundTrips() { + assertEquals(folder, WatchedFolder.fromJson(folder.toJson())) + val withoutPath = folder.copy(path = null) + assertEquals(withoutPath, WatchedFolder.fromJson(withoutPath.toJson())) + } + + @Test + fun json_rejectsGarbage() { + assertNull(WatchedFolder.fromJson("not json")) + assertNull(WatchedFolder.fromJson("{\"uri\":\"\"}")) + } + + @Test + fun isDue_onlyWhenWatchingAndWindowElapsed() { + assertFalse(folder.isDue(folder.lastScanAt + WATCH_RESCAN_INTERVAL_MS - 1)) + assertTrue(folder.isDue(folder.lastScanAt + WATCH_RESCAN_INTERVAL_MS)) + assertFalse(folder.copy(watching = false).isDue(folder.lastScanAt + WATCH_RESCAN_INTERVAL_MS)) + assertTrue(folder.copy(lastScanAt = 0L).isDue(1L + WATCH_RESCAN_INTERVAL_MS)) + } + + @Test + fun isDue_clockJumpedBackwards() { + assertTrue(folder.isDue(folder.lastScanAt - 1)) + } + + @Test + fun keys_seenKeyStartsWithFolderPrefix() { + val key = CacheKeys.watchSeen(folder.uri, "/storage/ROMs/a.gba") + assertTrue(key.startsWith(CacheKeys.watchSeenPrefix(folder.uri))) + assertEquals("watchfolder:${folder.uri}", CacheKeys.watchFolder(folder.uri)) + } +}