diff --git a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt index 2d129315..d550df12 100644 --- a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt +++ b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt @@ -37,6 +37,17 @@ class AWPreferences(context: Context) { sharedPreferences.edit().putBoolean("hasMigratedHostname", true).apply() } + fun hasMigratedWatcherAndroidBucketNames(): Boolean { + return sharedPreferences.getBoolean("hasMigratedWatcherAndroidBucketNames", false) + } + + fun setWatcherAndroidBucketNamesMigrated() { + sharedPreferences + .edit() + .putBoolean("hasMigratedWatcherAndroidBucketNames", true) + .apply() + } + fun hasRequestedNotificationPermission(): Boolean { return sharedPreferences.getBoolean("hasRequestedNotificationPermission", false) } diff --git a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt index e202e0b3..99196731 100644 --- a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt +++ b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt @@ -86,31 +86,24 @@ class BackgroundService : Service() { // Start the server rustInterface.startServerTask() - // Run hostname migration off the main thread — migrateHostname() is a blocking JNI call. + // 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) - if (!prefs.hasMigratedHostname()) { + val needsHostnameMigration = !prefs.hasMigratedHostname() + val needsWatcherBucketMigration = !prefs.hasMigratedWatcherAndroidBucketNames() + if (needsHostnameMigration || needsWatcherBucketMigration) { CoroutineScope(Dispatchers.IO).launch { - val hostname = rustInterface.getDeviceName(this@BackgroundService) - val result = rustInterface.migrateHostname(hostname) - Log.i(TAG, "Hostname migration result: $result") - // The native migrateHostname() returns a plain-text success string - // ("Migrated hostname for N bucket(s)") or a JSON error object - // ({"error": "..."}). Treat the plain-text prefix as success; only - // retry when the native lib explicitly reports an error. - val migrationSucceeded = if (result.startsWith("Migrated hostname for")) { - true - } else { - val errorMsg = try { - JSONObject(result).optString("error", result) - } catch (e: JSONException) { - result.ifEmpty { "empty response" } + if (needsHostnameMigration) { + val hostname = rustInterface.getDeviceName(this@BackgroundService) + val result = rustInterface.migrateHostname(hostname) + Log.i(TAG, "Hostname migration result: $result") + if (migrationSucceeded(result, "Migrated hostname for", "Hostname")) { + prefs.setHostnameMigrated() } - Log.w(TAG, "Hostname migration failed ($errorMsg); will retry on next start") - false } - if (migrationSucceeded) { - prefs.setHostnameMigrated() + if (needsWatcherBucketMigration) { + migrateWatcherAndroidTestBuckets(prefs) } } } @@ -134,6 +127,44 @@ class BackgroundService : Service() { return START_STICKY } + 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 + // Rust function alone does nothing until this path runs. + // + // Rename-only is in the current submodule pin. Collision merge (both + // buckets exist) needs ActivityWatch/aw-server-rust#661; until that SHA + // is bumped, leftover test buckets stay and we retry on the next start. + val result = rustInterface.migrateWatcherAndroidBucketNames() + Log.i(TAG, "Watcher bucket migration result: $result") + if (!WatcherAndroidBucketMigration.migrationSucceeded(result)) { + migrationSucceeded(result, WatcherAndroidBucketMigration.SUCCESS_PREFIX, "Watcher bucket") + return + } + val leftover = WatcherAndroidBucketMigration.legacyBucketIds(rustInterface.getBucketsJSON()) + if (WatcherAndroidBucketMigration.shouldMarkComplete(leftover)) { + prefs.setWatcherAndroidBucketNamesMigrated() + } else { + Log.w( + TAG, + "Watcher bucket migration left legacy bucket(s) $leftover; will retry on next start" + ) + } + } + + private fun migrationSucceeded(result: String, successPrefix: String, name: String): Boolean { + if (result.startsWith(successPrefix)) { + return true + } + val errorMsg = try { + JSONObject(result).optString("error", result) + } catch (e: JSONException) { + result.ifEmpty { "empty response" } + } + Log.w(TAG, "$name migration failed ($errorMsg); will retry on next start") + return false + } + private fun scheduleNotifyChecks() { val notifyRequest = androidx.work.PeriodicWorkRequest.Builder( net.activitywatch.android.workers.NotifyWorker::class.java, diff --git a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt index 16360637..29244ec3 100644 --- a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt @@ -80,6 +80,7 @@ class RustInterface(context: Context? = null) { external fun androidQuery(timeperiods: String): String external fun getSetting(key: String): String external fun migrateHostname(hostname: String): String + external fun migrateWatcherAndroidBucketNames(): String fun sayHello(to: String): String { return greeting(to) diff --git a/mobile/src/main/java/net/activitywatch/android/WatcherAndroidBucketMigration.kt b/mobile/src/main/java/net/activitywatch/android/WatcherAndroidBucketMigration.kt new file mode 100644 index 00000000..eb16b80b --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/WatcherAndroidBucketMigration.kt @@ -0,0 +1,30 @@ +package net.activitywatch.android + +import org.json.JSONObject + +/** + * Naming and completion rules for migrating `aw-watcher-android-test*` buckets + * onto the canonical `aw-watcher-android*` names. + * + * The actual event move lives in aw-server-rust (`migrate_test_bucket_names`). + * Kotlin is responsible for invoking that JNI entry and for not marking the + * preference complete while a legacy bucket still exists (overlap leftovers). + */ +object WatcherAndroidBucketMigration { + const val LEGACY_PREFIX = "aw-watcher-android-test" + const val CANONICAL_PREFIX = "aw-watcher-android" + const val SUCCESS_PREFIX = "Migrated " + + fun isLegacyBucketId(id: String): Boolean = id.startsWith(LEGACY_PREFIX) + + fun canonicalBucketId(legacyId: String): String = + legacyId.replaceFirst(LEGACY_PREFIX, CANONICAL_PREFIX) + + fun legacyBucketIds(buckets: JSONObject): List = + buckets.keys().asSequence().filter(::isLegacyBucketId).toList() + + fun migrationSucceeded(result: String): Boolean = result.startsWith(SUCCESS_PREFIX) + + fun shouldMarkComplete(legacyIdsRemaining: Collection): Boolean = + legacyIdsRemaining.isEmpty() +} diff --git a/mobile/src/test/java/net/activitywatch/android/WatcherAndroidBucketMigrationTest.kt b/mobile/src/test/java/net/activitywatch/android/WatcherAndroidBucketMigrationTest.kt new file mode 100644 index 00000000..12eba8a4 --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/WatcherAndroidBucketMigrationTest.kt @@ -0,0 +1,66 @@ +package net.activitywatch.android + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WatcherAndroidBucketMigrationTest { + @Test + fun canonicalBucketId_stripsTestInfix() { + assertEquals( + "aw-watcher-android_pixel_8", + WatcherAndroidBucketMigration.canonicalBucketId("aw-watcher-android-test_pixel_8"), + ) + } + + @Test + fun isLegacyBucketId_matchesPrefixOnly() { + assertTrue(WatcherAndroidBucketMigration.isLegacyBucketId("aw-watcher-android-test_phone")) + assertFalse(WatcherAndroidBucketMigration.isLegacyBucketId("aw-watcher-android_phone")) + assertFalse(WatcherAndroidBucketMigration.isLegacyBucketId("aw-watcher-web")) + } + + @Test + fun legacyBucketIds_filtersGetBucketsPayload() { + val buckets = JSONObject() + buckets.put("aw-watcher-android-test_phone", JSONObject()) + buckets.put("aw-watcher-android_phone", JSONObject()) + buckets.put("aw-watcher-web", JSONObject()) + + assertEquals( + listOf("aw-watcher-android-test_phone"), + WatcherAndroidBucketMigration.legacyBucketIds(buckets), + ) + } + + @Test + fun shouldMarkComplete_onlyWhenNoLegacyBucketsRemain() { + assertTrue(WatcherAndroidBucketMigration.shouldMarkComplete(emptyList())) + assertFalse( + WatcherAndroidBucketMigration.shouldMarkComplete( + listOf("aw-watcher-android-test_phone"), + ), + ) + } + + @Test + fun migrationSucceeded_acceptsCountIncludingZero() { + assertTrue( + WatcherAndroidBucketMigration.migrationSucceeded( + "Migrated 0 'aw-watcher-android-test' bucket(s)", + ), + ) + assertTrue( + WatcherAndroidBucketMigration.migrationSucceeded( + "Migrated 2 'aw-watcher-android-test' bucket(s)", + ), + ) + assertFalse( + WatcherAndroidBucketMigration.migrationSucceeded( + """{"error": "Failed to migrate watcher bucket names: boom"}""", + ), + ) + } +}