diff --git a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt index 01540e74..db885718 100644 --- a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt +++ b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt @@ -45,6 +45,17 @@ class AWPreferences(context: Context) { sharedPreferences.edit().putBoolean("hasMigratedHostname", true).apply() } + // Sanitized-hostname follow-up (ActivityWatch/aw-android#272). Distinct from + // hasMigratedHostname, which only rewrites unknown/Unknown and is already true + // on devices that still store the pre-#183 marketing name. + fun sanitizedHostnameMigratedTo(): String? { + return sharedPreferences.getString("sanitizedHostnameMigratedTo", null) + } + + fun setSanitizedHostnameMigratedTo(hostname: String) { + sharedPreferences.edit().putString("sanitizedHostnameMigratedTo", hostname).apply() + } + fun hasMigratedWatcherAndroidBucketNames(): Boolean { return sharedPreferences.getBoolean("hasMigratedWatcherAndroidBucketNames", false) } diff --git a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt index 6a3a8333..b63f11e9 100644 --- a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt +++ b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt @@ -14,6 +14,7 @@ import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -40,8 +41,60 @@ class BackgroundService : Service() { const val START_ORIGIN_BOOT = "boot" const val START_ORIGIN_SETTINGS = "settings" const val START_ORIGIN_SYSTEM_RESTART = "system-restart" + + // How long the queued hostname rewrite waits for the server task to exit + // before giving up (the next service start retries). + private const val SERVER_EXIT_POLL_MS = 1_000L + + // How long cancelQueuedHostnameRewrite() waits for the interrupted rewrite + // thread to finish. The interrupt wakes the poll sleep immediately, so this + // only needs to cover the poll-wake plus the thread's finally block — + // milliseconds. It must NOT be long: onDestroy runs on the main thread, and + // the only case where the join would wait longer is the thread being + // mid-SQLite-rewrite — there interrupt() cannot cancel the transaction, and + // the right outcome is to let it finish in the background (it completes the + // rewrite and sets the preference itself), not to stall teardown for it. + private const val CANCEL_JOIN_MS = 500L + + // Only one deferred bucket-hostname rewrite may be queued per process. The + // guard is static because Android recreates the service instance on every + // full start; an instance-level flag would let each recreation stack + // another polling thread that later races to rewrite the same database. + @Volatile + private var hostnameRewriteQueued = false + + @Volatile + private var hostnameRewriteThread: Thread? = null + + // Static so the deferred rewrite thread — which outlives the service + // instance that queued it — and a recreated instance's server start are + // serialized on the same monitor. An instance-level lock would let a new + // instance open the database while the old thread is still rewriting it. + val sanitizedMigrationLock = Any() + + fun cancelQueuedHostnameRewrite() { + val thread = hostnameRewriteThread ?: return + thread.interrupt() + // Bounded join: Android can recreate the service and run + // migrateSanitizedHostnameIdentity() before the interrupted thread's + // finally block clears hostnameRewriteQueued. The guard is shared via + // the companion object, so the recreated start would see it set, skip + // queueing, and miss the rewrite until another full start (which + // Android does not guarantee). Joining here ensures the flag is + // settled before a recreated instance runs the migration. If the + // timeout lapses the thread is mid-rewrite (it will complete and set + // the preference itself), never wedged in its poll loop — the poll + // wakes on interrupt immediately. + try { + thread.join(CANCEL_JOIN_MS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } } + + private lateinit var syncScheduler: SyncScheduler private lateinit var rustInterface: RustInterface @@ -55,6 +108,13 @@ class BackgroundService : Service() { // commands; queueing them twice doubled the startup stall in aw-android#261. private var migrationsQueued = false + // Set in onDestroy. The server start runs on an IO coroutine that can outlive + // the call to onStartCommand, so it re-checks this before opening the + // datastore: a service torn down while the start was waiting must not leave a + // running server behind. `Service` has no `isDestroyed` before API 35. + @Volatile + private var serviceDestroyed = false + override fun onCreate() { super.onCreate() Log.i(TAG, "BackgroundService created") @@ -114,13 +174,38 @@ class BackgroundService : Service() { // exists here as well. ensureDashboardApiKey(this) - // Start the server - rustInterface.startServerTask() + val prefs = AWPreferences(this) + // The sanitized-hostname migration traverses the sync tree and opens + // sqlite.db for a write transaction — doing that synchronously on the + // service main thread can ANR startup on a large sync tree or a busy + // database (same class as the #262 startup hang). Run it on IO and + // sequence the server start after it so the server never opens the + // database before the rewrite has completed or explicitly deferred. + CoroutineScope(Dispatchers.IO).launch { + synchronized(sanitizedMigrationLock) { + // onDestroy can run on the main thread as soon as onStartCommand + // returns, so the service may already be gone by the time this + // coroutine acquires the lock. Starting the server then would + // leave a running datastore with no service to stop it. This + // check cannot cover a destroy that lands after + // startServerTask() has begun (blocking JNI, not interruptible); + // it closes the window that is actually reachable — the wait for + // the lock and the migration. + if (serviceDestroyed) { + Log.i(TAG, "Service destroyed while the server start was waiting; skipping") + return@launch + } + migrateSanitizedHostnameIdentity(prefs) + // Under the same lock as the migration so overlapping + // onStartCommand invocations and the deferred rewrite thread + // can never interleave with the server opening the database. + rustInterface.startServerTask() + } + } // Run hostname + legacy-bucket migrations off the main thread — both are blocking JNI. // Only mark as migrated on success so a retry is possible if the server wasn't ready yet. // Watcher-bucket migration must follow hostname migration so IDs are stable first. - val prefs = AWPreferences(this) val needsHostnameMigration = !prefs.hasMigratedHostname() val needsWatcherBucketMigration = !prefs.hasMigratedWatcherAndroidBucketNames() if ((needsHostnameMigration || needsWatcherBucketMigration) && !migrationsQueued) { @@ -161,6 +246,124 @@ class BackgroundService : Service() { return BACKGROUND_SERVICE_RESTART_MODE } + /** + * Rewrite unsanitized bucket hostnames and fold leftover sync folders before + * the datastore worker opens sqlite.db. Must run before [startServerTask]. + */ + private fun migrateSanitizedHostnameIdentity(prefs: AWPreferences) { + val current = deviceHostname(this) + val legacy = legacyDeviceHostnames(this) + + val syncDir = existingAwSyncDirectory(this) + if (syncDir != null) { + val deviceId = + File(filesDir, "device_id").takeIf { it.isFile }?.readText()?.trim()?.takeIf { + it.isNotEmpty() + } + val result = + SanitizedHostnameMigration.migrateSyncFolders(syncDir, current, legacy, deviceId) + if (result.moved > 0) { + Log.i(TAG, "Migrated ${result.moved} leftover sync-folder entries to '$current'") + } + if (result.failed > 0) { + Log.w( + TAG, + "${result.failed} sync-folder migration action(s) failed; " + + "the next start retries", + ) + } + } + + if (prefs.sanitizedHostnameMigratedTo() == current) return + val dbFile = File(filesDir, "sqlite.db") + if (!dbFile.isFile) { + prefs.setSanitizedHostnameMigratedTo(current) + return + } + if (RustInterface.serverStarted) { + // The datastore worker owns sqlite.db while the server runs; a raw + // rewrite under it is unsafe. Queue the rewrite for when the server + // task exits instead of skipping silently — a silent skip here would + // leave the preference unset and the rewrite would never converge + // (every later start sees serverStarted true again). + Log.i(TAG, "Datastore already open; queueing bucket hostname rewrite until the server task exits") + queueHostnameRewriteAfterServerExit(prefs, dbFile, current, legacy) + return + } + val updated = + SanitizedHostnameMigration.rewriteBucketHostnamesInDatabase(dbFile, current, legacy) + if (updated >= 0) { + prefs.setSanitizedHostnameMigratedTo(current) + } + } + + private fun queueHostnameRewriteAfterServerExit( + prefs: AWPreferences, + dbFile: File, + current: String, + legacy: List, + ) { + if (hostnameRewriteQueued) { + val existing = hostnameRewriteThread + if (existing?.isAlive == true) { + Log.i(TAG, "Hostname rewrite already queued; not queueing another") + return + } + // Stale guard: the owning thread is gone without having cleared the + // flag (defensive — the finally block clears it, but a lapsed cancel + // join must not leave the rewrite permanently blocked). Reset and + // queue a fresh rewrite. + Log.w(TAG, "Hostname rewrite guard stale (owner thread not alive); re-queueing") + hostnameRewriteQueued = false + } + hostnameRewriteQueued = true + hostnameRewriteThread = Thread { + try { + // No wait bound: a long-running background service may keep the + // server task alive for days, and a bounded poll would time out + // with the rewrite permanently deferred (every later start sees + // serverStarted true again). The server task exits when the + // service is destroyed (or the process dies); onDestroy + // interrupts this thread so teardown does not leave it polling, + // and the unset preference makes the next start re-queue it. + try { + while (RustInterface.serverStarted) { + Thread.sleep(SERVER_EXIT_POLL_MS) + } + } catch (_: InterruptedException) { + return@Thread + } + synchronized(sanitizedMigrationLock) { + // Re-check under the lock: the server start is serialized on + // the same monitor, so a start that raced past the poll loop + // cannot open the database while the rewrite runs. + if (RustInterface.serverStarted) { + Log.i(TAG, "Server started while rewrite was queued; deferring to the next start") + return@Thread + } + val updated = + SanitizedHostnameMigration.rewriteBucketHostnamesInDatabase( + dbFile, + current, + legacy, + ) + if (updated >= 0) { + prefs.setSanitizedHostnameMigratedTo(current) + } + } + } finally { + // Clear the guard whether the rewrite ran, failed (a later start + // retries), or the thread was interrupted before the server exited. + hostnameRewriteThread = null + hostnameRewriteQueued = false + } + }.apply { + name = "sanitized-hostname-rewrite" + isDaemon = true + start() + } + } + private fun migrateWatcherAndroidTestBuckets(prefs: AWPreferences) { // Older production releases wrote activity into aw-watcher-android-test. // The JNI is the only caller of migrate_test_bucket_names(); shipping the @@ -246,6 +449,8 @@ class BackgroundService : Service() { override fun onDestroy() { Log.i(TAG, "BackgroundService destroyed") + serviceDestroyed = true + cancelQueuedHostnameRewrite() if (::syncScheduler.isInitialized) syncScheduler.stop() super.onDestroy() } diff --git a/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt b/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt index 1c57897e..493b592b 100644 --- a/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt +++ b/mobile/src/main/java/net/activitywatch/android/DeviceHostname.kt @@ -20,9 +20,17 @@ internal fun sanitizeDeviceHostname(raw: String?): String { .ifEmpty { "unknown" } } -internal fun deviceHostname(context: Context): String { - val named = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) +internal fun rawDeviceName(context: Context): String? = + Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) ?.trim() ?.takeIf { it.isNotEmpty() } - return sanitizeDeviceHostname(named ?: android.os.Build.DEVICE) -} + +internal fun deviceHostname(context: Context): String = + sanitizeDeviceHostname(rawDeviceName(context) ?: android.os.Build.DEVICE) + +internal fun legacyDeviceHostnames(context: Context): List = + SanitizedHostnameMigration.legacyHostnames( + current = deviceHostname(context), + deviceName = rawDeviceName(context), + model = android.os.Build.MODEL, + ) diff --git a/mobile/src/main/java/net/activitywatch/android/SanitizedHostnameMigration.kt b/mobile/src/main/java/net/activitywatch/android/SanitizedHostnameMigration.kt new file mode 100644 index 00000000..7e4b6d74 --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/SanitizedHostnameMigration.kt @@ -0,0 +1,356 @@ +package net.activitywatch.android + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.util.Log +import java.io.File + +/** + * One-shot repair for ActivityWatch/aw-android#272: sanitizing the device hostname + * (PR #183) forked already-syncing devices onto a new folder name without moving + * the old folder or rewriting bucket `hostname` rows. + * + * The sanitization itself stays. This migrates leftover identity so peers see one + * device, not `POCO F8 Ultra/` plus `poco_f8_ultra/`. + */ +object SanitizedHostnameMigration { + private const val TAG = "SanitizedHostnameMigration" + + sealed class FolderAction { + data class RenameHostnameDir(val from: String, val to: String) : FolderAction() + + data class MoveDeviceDir( + val fromHostname: String, + val toHostname: String, + val deviceId: String, + ) : FolderAction() + + data class DeleteStaleDeviceDir(val hostname: String, val deviceId: String) : FolderAction() + + data class DeleteHostnameDir(val hostname: String) : FolderAction() + } + + fun isSafeDirName(name: String): Boolean { + if (name.isEmpty() || name == "." || name == "..") return false + if (name.contains('\u0000')) return false + return !name.contains('/') && !name.contains('\\') + } + + fun legacyHostnames(current: String, deviceName: String?, model: String?): List { + val candidates = + listOfNotNull( + deviceName?.trim()?.takeIf { it.isNotEmpty() }, + model?.trim()?.takeIf { it.isNotEmpty() }, + "Unknown", + "unknown", + ) + return candidates.distinct().filter { it != current && isSafeDirName(it) } + } + + fun hostnamesToRewrite( + existingHostnames: Iterable, + current: String, + legacy: Collection, + ): List { + val wanted = legacy.toSet() + return existingHostnames.distinct().filter { it != current && it in wanted } + } + + fun planFolderMigration( + existingHostnameDirs: Set, + deviceIdsByHostname: Map>, + currentHostname: String, + legacyHostnames: Collection, + localDeviceId: String?, + // Hostname dirs holding entries the planner cannot account for (regular + // files, or directories it will not treat as device ids). + // applyFolderMigration refuses to delete a non-empty hostname dir, so + // planning a delete for one of these would be rejected, counted as + // failed, and retried on every sync forever. + hostnameDirsWithUnmanagedEntries: Set = emptySet(), + ): List { + if (!isSafeDirName(currentHostname)) return emptyList() + val actions = mutableListOf() + val newExists = currentHostname in existingHostnameDirs + val scopedId = localDeviceId?.takeIf { isSafeDirName(it) } + + val presentLegacy = + legacyHostnames + .distinct() + .filter { it != currentHostname && isSafeDirName(it) && it in existingHostnameDirs } + + for (legacy in legacyHostnames.distinct()) { + if (legacy == currentHostname || !isSafeDirName(legacy)) continue + if (legacy !in existingHostnameDirs) continue + val legacyIds = deviceIdsByHostname[legacy].orEmpty() + val newIds = deviceIdsByHostname[currentHostname].orEmpty() + + if (scopedId != null) { + if (scopedId !in legacyIds) { + if (legacyIds.isEmpty() && legacy !in hostnameDirsWithUnmanagedEntries) { + actions.add(FolderAction.DeleteHostnameDir(legacy)) + } + continue + } + when { + scopedId !in newIds -> { + if (legacyIds == setOf(scopedId) && !newExists) { + actions.add(FolderAction.RenameHostnameDir(legacy, currentHostname)) + } else { + actions.add( + FolderAction.MoveDeviceDir( + fromHostname = legacy, + toHostname = currentHostname, + deviceId = scopedId, + ) + ) + if (legacyIds == setOf(scopedId)) { + actions.add(FolderAction.DeleteHostnameDir(legacy)) + } + } + } + else -> { + actions.add(FolderAction.DeleteStaleDeviceDir(legacy, scopedId)) + if (legacyIds == setOf(scopedId) && + legacy !in hostnameDirsWithUnmanagedEntries + ) { + actions.add(FolderAction.DeleteHostnameDir(legacy)) + } + } + } + } else { + // No local device id: a legacy dir cannot be attributed to this + // device, so renaming every candidate could fold another device's + // data under the current hostname. Only fold a single unambiguous + // candidate (nothing else could claim it); leave the rest for a + // start where the device id is available. + val legacyDeviceIds = deviceIdsByHostname[legacy].orEmpty() + when { + // A wholesale rename is only safe when the legacy dir cannot + // carry other devices' data: at most one device-id subdir. + // A dir holding several device ids could belong to more than + // one device, and renaming it would fold all of them under + // the current hostname. + !newExists && presentLegacy.size == 1 && legacyDeviceIds.size <= 1 -> + actions.add(FolderAction.RenameHostnameDir(legacy, currentHostname)) + // Delete only when the dir is empty (no device-id subdirs remain). + // If it has device-id subdirs that can't be attributed without the + // local device id, skip rather than planning a delete that + // applyFolderMigration will reject: a rejected delete counts as + // "failed" and prevents ensureLegacyFoldersMigrated from marking + // the per-instance migration done, causing it to retry every sync. + newExists && + legacyDeviceIds.isEmpty() && + legacy !in hostnameDirsWithUnmanagedEntries -> + actions.add(FolderAction.DeleteHostnameDir(legacy)) + else -> + info( + "Leaving legacy dir '$legacy' in place; local device id " + + "unavailable or dir holds unattributable device entries" + ) + } + } + } + return actions + } + + fun applyFolderMigration(syncDir: File, actions: List): Int { + var applied = 0 + for (action in actions) { + val ok = + when (action) { + is FolderAction.RenameHostnameDir -> + renameDir(File(syncDir, action.from), File(syncDir, action.to)) + is FolderAction.MoveDeviceDir -> { + val destHost = File(syncDir, action.toHostname) + if (!destHost.exists() && !destHost.mkdirs()) { + warn("Could not create ${destHost.path}") + false + } else { + renameDir( + File(File(syncDir, action.fromHostname), action.deviceId), + File(destHost, action.deviceId), + ) + } + } + is FolderAction.DeleteStaleDeviceDir -> + deleteRecursively(File(File(syncDir, action.hostname), action.deviceId)) + is FolderAction.DeleteHostnameDir -> { + val dir = File(syncDir, action.hostname) + val leftover = dir.listFiles()?.isNotEmpty() == true + if (leftover) { + info("Leaving ${dir.path}; it still has other entries") + false + } else { + deleteRecursively(dir) + } + } + } + if (ok) applied++ + } + return applied + } + + fun migrateSyncFolders( + syncDir: File, + currentHostname: String, + legacyHostnames: Collection, + localDeviceId: String?, + ): MigrationResult { + // Serialize folder migration process-wide: BackgroundService (service + // start) and SyncInterface (first sync) can run this concurrently, and + // both would plan from and mutate the same on-disk directories. + synchronized(folderMigrationLock) { + return migrateSyncFoldersLocked(syncDir, currentHostname, legacyHostnames, localDeviceId) + } + } + + private val folderMigrationLock = Any() + + private fun migrateSyncFoldersLocked( + syncDir: File, + currentHostname: String, + legacyHostnames: Collection, + localDeviceId: String?, + ): MigrationResult { + if (!syncDir.isDirectory) return MigrationResult(0, 0) + val deviceIdsByHostname = linkedMapOf>() + val hostnameDirs = mutableSetOf() + val unmanagedEntryDirs = mutableSetOf() + val children = syncDir.listFiles() ?: return MigrationResult(0, 0) + for (child in children) { + if (!child.isDirectory || !isSafeDirName(child.name)) continue + hostnameDirs.add(child.name) + val kids = child.listFiles() + val ids = + kids + ?.filter { it.isDirectory && isSafeDirName(it.name) } + ?.map { it.name } + ?.toSet() + .orEmpty() + deviceIdsByHostname[child.name] = ids + // A failed listing (kids == null) means we cannot tell whether the dir + // is empty; treat it as holding unmanaged entries rather than planning + // a delete the applier would reject. + if (kids == null || kids.any { !(it.isDirectory && isSafeDirName(it.name)) }) { + unmanagedEntryDirs.add(child.name) + } + } + val actions = + planFolderMigration( + existingHostnameDirs = hostnameDirs, + deviceIdsByHostname = deviceIdsByHostname, + currentHostname = currentHostname, + legacyHostnames = legacyHostnames, + localDeviceId = localDeviceId, + hostnameDirsWithUnmanagedEntries = unmanagedEntryDirs, + ) + if (actions.isEmpty()) return MigrationResult(0, 0) + info("Applying ${actions.size} sync-folder migration action(s) under ${syncDir.path}") + val applied = applyFolderMigration(syncDir, actions) + return MigrationResult(applied, actions.size - applied) + } + + /** + * Outcome of one [migrateSyncFolders] pass. [failed] counts planned actions + * that could not be applied (rename/delete rejected by the filesystem); + * callers that record "migration complete" must treat [failed] > 0 as + * not-done so the pass is retried on the next sync. + */ + data class MigrationResult(val moved: Int, val failed: Int) + + fun rewriteBucketHostnamesInDatabase( + dbFile: File, + currentHostname: String, + fromHostnames: Collection, + ): Int { + if (!dbFile.isFile || currentHostname.isEmpty()) return 0 + val from = fromHostnames.filter { it.isNotEmpty() && it != currentHostname }.distinct() + if (from.isEmpty()) return 0 + val db = + try { + SQLiteDatabase.openDatabase( + dbFile.absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE, + ) + } catch (e: SQLiteException) { + warn("Could not open ${dbFile.path} to rewrite hostnames: ${e.message}") + return -1 + } + try { + db.beginTransaction() + try { + var updated = 0 + for (legacy in from) { + // Only locally-owned buckets may be relabeled: this device's + // legacy hostname candidates (device name, model, "Unknown") + // can collide with a synced peer's hostname, and rewriting a + // peer's bucket rows would corrupt its identity on the next + // sync. `-synced-from-` is a reserved token in aw-sync's ID + // grammar and marks remote-origin buckets (aw-server-rust + // aw-sync `is_synced_bucket` / `get_or_create_sync_bucket`). + val stmt = + db.compileStatement( + "UPDATE buckets SET hostname = ? " + + "WHERE hostname = ? AND id NOT LIKE '%-synced-from-%'" + ) + try { + stmt.bindString(1, currentHostname) + stmt.bindString(2, legacy) + updated += stmt.executeUpdateDelete() + } finally { + stmt.close() + } + } + db.setTransactionSuccessful() + if (updated > 0) { + info("Rewrote hostname on $updated bucket(s) to '$currentHostname'") + } + return updated + } finally { + db.endTransaction() + } + } catch (e: SQLiteException) { + warn("Bucket hostname rewrite failed on ${dbFile.path}: ${e.message}") + return -1 + } finally { + db.close() + } + } + + private fun renameDir(from: File, to: File): Boolean { + if (!from.exists()) return false + if (to.exists()) { + warn("Refusing to rename ${from.path} onto existing ${to.path}") + return false + } + if (from.renameTo(to)) return true + warn("renameTo failed for ${from.path} → ${to.path}") + return false + } + + private fun deleteRecursively(file: File): Boolean { + if (!file.exists()) return false + val deleted = file.deleteRecursively() + if (!deleted) { + warn("Failed to delete ${file.path}") + } + return deleted + } + + // android.util.Log throws on the JVM unit-test stub. + private fun info(message: String) { + try { + Log.i(TAG, message) + } catch (_: RuntimeException) { + } + } + + private fun warn(message: String) { + try { + Log.w(TAG, message) + } catch (_: RuntimeException) { + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index e80d0655..5f78a305 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -46,6 +46,38 @@ class SyncInterface(context: Context) { */ @Volatile private var cancelRequested = false + /** + * Legacy-folder migration is deferred to the first sync operation instead of + * running in init: SyncInterface is constructed on the main thread (service + * onCreate), and the migration traverses and renames sync directories, which + * can ANR on a large or slow-storage sync dir. Sync workers run on background + * executors, and performSyncAsync is the single funnel they all pass through, + * so the first sync always happens after the migration has completed. + */ + private val migrationLock = Any() + + @Volatile private var legacyFoldersMigrated = false + + private fun ensureLegacyFoldersMigrated() { + if (legacyFoldersMigrated) return + synchronized(migrationLock) { + if (legacyFoldersMigrated) return + // Only record completion when every planned action applied. A failed + // rename or deletion leaves the flag unset so the next sync re-runs + // the migration instead of skipping it with the fork still in place. + val result = migrateLegacySyncFolders() + if (result.failed == 0) { + legacyFoldersMigrated = true + } else { + Log.w( + TAG, + "${result.failed} legacy-folder migration action(s) failed; " + + "retrying on the next sync", + ) + } + } + } + init { syncDir = resolveSyncDirectory(context).absolutePath Os.setenv("AW_SYNC_DIR", syncDir, true) @@ -178,6 +210,7 @@ class SyncInterface(context: Context) { executor.execute { Log.i(TAG, "Starting sync operation: $operation") try { + ensureLegacyFoldersMigrated() val response = syncFn() val json = JSONObject(response) val success = json.getBoolean("success") @@ -230,6 +263,9 @@ class SyncInterface(context: Context) { * * Errors propagate to the caller so a configured directory is never reported as successfully * synced when the files could not be mirrored there. + * + * Stale legacy hostname directories in the SAF tree are removed only after the mirror + * succeeds, so a leftover local legacy folder can never be re-mirrored after deletion. */ private fun copySyncFilesToSafDir() { val uriStr = AWPreferences(appContext).getSyncDirUri() ?: return @@ -248,6 +284,13 @@ class SyncInterface(context: Context) { if (counts[1] > 0) { throw IOException("SAF mirror skipped ${counts[1]} item(s)") } + // Stale-SAF cleanup runs only after a fully successful mirror: deleting + // first would let the mirror re-copy a leftover local legacy folder back + // into the SAF tree (the fork would persist), and deleting after a + // partial mirror could drop data the local copy still holds. If the + // local legacy folder could not be renamed, it is deleted here each run + // after being mirrored, so the Syncthing-visible fork stays gone. + deleteStaleSafHostnameDirs(safDir) } /** @@ -322,4 +365,82 @@ class SyncInterface(context: Context) { } fun getSyncDirectory(): String = syncDir + + private fun migrateLegacySyncFolders(): SanitizedHostnameMigration.MigrationResult { + val current = getDeviceName() + val deviceId = + File(appContext.filesDir, "device_id").takeIf { it.isFile }?.readText()?.trim()?.takeIf { + it.isNotEmpty() + } + val result = + SanitizedHostnameMigration.migrateSyncFolders( + File(syncDir), + current, + SanitizedHostnameMigration.legacyHostnames( + current, + rawDeviceName(appContext), + android.os.Build.MODEL, + ), + deviceId, + ) + if (result.moved > 0) { + Log.i(TAG, "Migrated ${result.moved} leftover sync-folder entries to '$current'") + } + return result + } + + private fun deleteStaleSafHostnameDirs(safDir: DocumentFile) { + val current = getDeviceName() + val legacy = + SanitizedHostnameMigration.legacyHostnames( + current, + rawDeviceName(appContext), + android.os.Build.MODEL, + ) + val deviceId = + File(appContext.filesDir, "device_id").takeIf { it.isFile }?.readText()?.trim()?.takeIf { + it.isNotEmpty() + } + for (name in legacy) { + val stale = safDir.findFile(name) ?: continue + if (!stale.isDirectory) continue + val removed = + if (deviceId != null) { + // Scoped deletion: only remove this device's subdirectory, and + // only drop the hostname dir itself when nothing else remains. + // A dir holding other devices' data is never destroyed. + val deviceDir = stale.findFile(deviceId) + val deviceGone = deviceDir == null || deleteDocumentRecursively(deviceDir) + val empty = stale.listFiles().isEmpty() + deviceGone && (!empty || stale.delete()) + } else { + // Without the local device id we cannot tell whether a legacy + // hostname dir belongs to this device; deleting it wholesale + // could destroy other devices' synced data. Leave it. + Log.w(TAG, "Leaving stale SAF hostname dir '$name'; local device id unavailable") + false + } + if (removed) { + Log.i(TAG, "Removed stale SAF hostname dir '$name'") + } else { + Log.w(TAG, "Could not remove stale SAF hostname dir '$name'") + } + } + } + + private fun deleteDocumentRecursively(doc: DocumentFile): Boolean { + if (doc.isDirectory) { + for (child in doc.listFiles()) { + if (!deleteDocumentRecursively(child)) return false + } + } + return doc.delete() + } +} + +internal fun existingAwSyncDirectory(context: Context): File? { + val preferred = File(context.getExternalFilesDir(null) ?: context.filesDir, "sync") + if (preferred.isDirectory) return preferred + val fallback = File(context.filesDir, "sync") + return fallback.takeIf { it.isDirectory } } diff --git a/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt b/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt index 16142602..a4aaa26a 100644 --- a/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/DeviceHostnameTest.kt @@ -17,5 +17,6 @@ class DeviceHostnameTest { assertEquals("pixel_8", sanitizeDeviceHostname("Pixel 8")) assertEquals("my-phone_1", sanitizeDeviceHostname("My-Phone_1")) assertEquals("pixel_8", sanitizeDeviceHostname(" Pixel 8 ")) + assertEquals("poco_f8_ultra", sanitizeDeviceHostname("POCO F8 Ultra")) } } diff --git a/mobile/src/test/java/net/activitywatch/android/SanitizedHostnameMigrationTest.kt b/mobile/src/test/java/net/activitywatch/android/SanitizedHostnameMigrationTest.kt new file mode 100644 index 00000000..1a7baacd --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/SanitizedHostnameMigrationTest.kt @@ -0,0 +1,327 @@ +package net.activitywatch.android + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SanitizedHostnameMigrationTest { + @Test + fun legacyHostnames_includesUnsanitizedDeviceNameAndModel() { + assertEquals( + listOf("POCO F8 Ultra", "Poco F8 Ultra", "Unknown", "unknown"), + SanitizedHostnameMigration.legacyHostnames( + current = "poco_f8_ultra", + deviceName = "POCO F8 Ultra", + model = "Poco F8 Ultra", + ), + ) + } + + @Test + fun legacyHostnames_dropsNamesThatAlreadyMatch() { + assertEquals( + listOf("Unknown", "unknown"), + SanitizedHostnameMigration.legacyHostnames( + current = "poco_f8_ultra", + deviceName = "poco_f8_ultra", + model = "poco_f8_ultra", + ), + ) + } + + @Test + fun isSafeDirName_rejectsPathElements() { + assertFalse(SanitizedHostnameMigration.isSafeDirName("")) + assertFalse(SanitizedHostnameMigration.isSafeDirName("..")) + assertFalse(SanitizedHostnameMigration.isSafeDirName("a/b")) + assertFalse(SanitizedHostnameMigration.isSafeDirName("a\\b")) + assertTrue(SanitizedHostnameMigration.isSafeDirName("POCO F8 Ultra")) + assertTrue(SanitizedHostnameMigration.isSafeDirName("poco_f8_ultra")) + } + + @Test + fun hostnamesToRewrite_onlyTouchesKnownLegacyValues() { + assertEquals( + listOf("POCO F8 Ultra"), + SanitizedHostnameMigration.hostnamesToRewrite( + existingHostnames = listOf("POCO F8 Ultra", "erik-mac", "poco_f8_ultra"), + current = "poco_f8_ultra", + legacy = listOf("POCO F8 Ultra", "Unknown"), + ), + ) + } + + @Test + fun plan_renamesLegacyDirWhenSanitizedNameIsAbsent() { + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra"), + deviceIdsByHostname = mapOf("POCO F8 Ultra" to setOf("dev-1")), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = "dev-1", + ) + assertEquals( + listOf( + SanitizedHostnameMigration.FolderAction.RenameHostnameDir( + "POCO F8 Ultra", + "poco_f8_ultra", + ) + ), + actions, + ) + } + + @Test + fun plan_deletesStaleForkWhenBothDirsExistForSameDevice() { + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra", "poco_f8_ultra"), + deviceIdsByHostname = + mapOf( + "POCO F8 Ultra" to setOf("dev-1"), + "poco_f8_ultra" to setOf("dev-1"), + ), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = "dev-1", + ) + assertEquals( + listOf( + SanitizedHostnameMigration.FolderAction.DeleteStaleDeviceDir("POCO F8 Ultra", "dev-1"), + SanitizedHostnameMigration.FolderAction.DeleteHostnameDir("POCO F8 Ultra"), + ), + actions, + ) + } + + @Test + fun plan_movesOnlyThisDeviceWhenLegacyDirHasOtherIds() { + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra"), + deviceIdsByHostname = mapOf("POCO F8 Ultra" to setOf("dev-1", "other")), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = "dev-1", + ) + assertEquals( + listOf( + SanitizedHostnameMigration.FolderAction.MoveDeviceDir( + "POCO F8 Ultra", + "poco_f8_ultra", + "dev-1", + ) + ), + actions, + ) + } + + @Test + fun apply_renamesLegacyFolderOnDisk() { + val root = File.createTempFile("aw-sync-mig", null) + assertTrue(root.delete()) + assertTrue(root.mkdirs()) + try { + val deviceId = "41662faa-7dc4-4e50-970b-f986d59a1819" + val oldDb = File(root, "POCO F8 Ultra/$deviceId/test.db") + assertTrue(oldDb.parentFile?.mkdirs() == true) + oldDb.writeText("legacy") + + val applied = + SanitizedHostnameMigration.migrateSyncFolders( + syncDir = root, + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = deviceId, + ) + assertEquals(1, applied.moved) + assertFalse(File(root, "POCO F8 Ultra").exists()) + assertEquals("legacy", File(root, "poco_f8_ultra/$deviceId/test.db").readText()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun apply_removesStaleForkWhenSanitizedFolderAlreadyExists() { + val root = File.createTempFile("aw-sync-fork", null) + assertTrue(root.delete()) + assertTrue(root.mkdirs()) + try { + val deviceId = "41662faa-7dc4-4e50-970b-f986d59a1819" + val oldDb = File(root, "POCO F8 Ultra/$deviceId/test.db") + val newDb = File(root, "poco_f8_ultra/$deviceId/test.db") + assertTrue(oldDb.parentFile?.mkdirs() == true) + assertTrue(newDb.parentFile?.mkdirs() == true) + oldDb.writeText("old") + newDb.writeText("new") + + val applied = + SanitizedHostnameMigration.migrateSyncFolders( + syncDir = root, + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = deviceId, + ) + assertEquals(2, applied.moved) + assertFalse(File(root, "POCO F8 Ultra").exists()) + assertEquals("new", newDb.readText()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun plan_withoutDeviceId_renamesOnlySingleUnambiguousCandidate() { + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra"), + deviceIdsByHostname = mapOf("POCO F8 Ultra" to setOf("dev-1")), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = null, + ) + assertEquals( + listOf( + SanitizedHostnameMigration.FolderAction.RenameHostnameDir( + "POCO F8 Ultra", + "poco_f8_ultra", + ) + ), + actions, + ) + } + + @Test + fun plan_withoutDeviceId_leavesAmbiguousCandidatesAlone() { + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra", "Poco F8 Ultra"), + deviceIdsByHostname = + mapOf( + "POCO F8 Ultra" to setOf("dev-1"), + "Poco F8 Ultra" to setOf("other"), + ), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra", "Poco F8 Ultra"), + localDeviceId = null, + ) + assertTrue(actions.isEmpty()) + } + + @Test + fun plan_withoutDeviceId_leavesMultiDeviceLegacyDirAlone() { + // A legacy dir holding several device ids cannot be attributed to this + // device; renaming it wholesale would fold all of them under the current + // hostname. + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra"), + deviceIdsByHostname = mapOf("POCO F8 Ultra" to setOf("dev-1", "dev-2")), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = null, + ) + assertEquals(emptyList(), actions) + } + + @Test + fun plan_withoutDeviceId_andSanitizedDirExists_leavesLegacyDirWithDeviceEntries() { + // Legacy dir has device-id subdirs that can't be attributed without the local + // device id. Previously the planner added DeleteHostnameDir here, which + // applyFolderMigration always rejected (dir not empty), making MigrationResult + // report failed=1 and causing ensureLegacyFoldersMigrated to retry every sync. + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra", "poco_f8_ultra"), + deviceIdsByHostname = + mapOf( + "POCO F8 Ultra" to setOf("other"), + "poco_f8_ultra" to setOf("dev-1"), + ), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = null, + ) + assertEquals(emptyList(), actions) + } + + @Test + fun plan_withoutDeviceId_andSanitizedDirExists_deletesEmptyLegacyDir() { + // Legacy dir has no device-id subdirs (already cleaned up); safe to delete. + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra", "poco_f8_ultra"), + deviceIdsByHostname = + mapOf( + "POCO F8 Ultra" to emptySet(), + "poco_f8_ultra" to setOf("dev-1"), + ), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = null, + ) + assertEquals( + listOf(SanitizedHostnameMigration.FolderAction.DeleteHostnameDir("POCO F8 Ultra")), + actions, + ) + } + + @Test + fun plan_withoutDeviceId_andSanitizedDirExists_leavesLegacyDirHoldingOnlyFilesAlone() { + // A legacy dir whose only entries are regular files looks empty to the + // device-id map, but applyFolderMigration refuses to delete a dir with any + // entries. Planning that delete would make the pass report failed=1 and + // retry on every sync forever. + val actions = + SanitizedHostnameMigration.planFolderMigration( + existingHostnameDirs = setOf("POCO F8 Ultra", "poco_f8_ultra"), + deviceIdsByHostname = + mapOf( + "POCO F8 Ultra" to emptySet(), + "poco_f8_ultra" to setOf("dev-1"), + ), + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = null, + hostnameDirsWithUnmanagedEntries = setOf("POCO F8 Ultra"), + ) + assertEquals(emptyList(), actions) + } + + @Test + fun apply_doesNotFailOnLegacyDirWithStrayFileAlongsideStaleDeviceDir() { + val root = File.createTempFile("aw-sync-stray", null) + assertTrue(root.delete()) + assertTrue(root.mkdirs()) + try { + val deviceId = "41662faa-7dc4-4e50-970b-f986d59a1819" + val legacyDeviceDir = File(root, "POCO F8 Ultra/$deviceId") + val newDeviceDir = File(root, "poco_f8_ultra/$deviceId") + assertTrue(legacyDeviceDir.mkdirs()) + assertTrue(newDeviceDir.mkdirs()) + File(root, "POCO F8 Ultra/.nomedia").writeText("") + File(legacyDeviceDir, "test.db").writeText("old") + + val applied = + SanitizedHostnameMigration.migrateSyncFolders( + syncDir = root, + currentHostname = "poco_f8_ultra", + legacyHostnames = listOf("POCO F8 Ultra"), + localDeviceId = deviceId, + ) + // The stale device dir goes; the hostname dir cannot be deleted while + // the stray file is in it, and that must not count as a failure — + // otherwise the migration never marks itself done and retries forever. + assertEquals(1, applied.moved) + assertEquals(0, applied.failed) + assertFalse(legacyDeviceDir.exists()) + assertTrue(File(root, "POCO F8 Ultra/.nomedia").exists()) + } finally { + root.deleteRecursively() + } + } +}