Skip to content
Merged
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
11 changes: 11 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
71 changes: 51 additions & 20 deletions mobile/src/main/java/net/activitywatch/android/BackgroundService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> =
buckets.keys().asSequence().filter(::isLegacyBucketId).toList()

fun migrationSucceeded(result: String): Boolean = result.startsWith(SUCCESS_PREFIX)

fun shouldMarkComplete(legacyIdsRemaining: Collection<String>): Boolean =
legacyIdsRemaining.isEmpty()
}
Original file line number Diff line number Diff line change
@@ -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"}""",
),
)
}
}
Loading