From 15b2b2c775bb82c65086d6eb40008d9d929ff755 Mon Sep 17 00:00:00 2001 From: Giovanni Francesco Comune Date: Thu, 22 Oct 2026 20:31:50 +0200 Subject: [PATCH 1/2] fix(sync): start local server before background sync --- Makefile | 18 +++++- .../android/ColdStartSyncTest.kt | 57 +++++++++++++++++++ .../android/BackgroundService.kt | 32 +---------- .../activitywatch/android/SyncInterface.kt | 3 + .../net/activitywatch/android/SyncServer.kt | 51 +++++++++++++++++ .../android/SyncServerReadiness.kt | 21 +++++++ .../android/workers/SyncWorker.kt | 2 +- .../android/SyncServerReadinessTest.kt | 40 +++++++++++++ 8 files changed, 190 insertions(+), 34 deletions(-) create mode 100644 mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt create mode 100644 mobile/src/main/java/net/activitywatch/android/SyncServer.kt create mode 100644 mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt create mode 100644 mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt diff --git a/Makefile b/Makefile index db1e1019..12c2b42e 100644 --- a/Makefile +++ b/Makefile @@ -65,16 +65,30 @@ test-unit: # The upgrade test seeds a large legacy database before the datastore opens, so it # needs its own instrumentation process: run it after the rest, in a separate # gradle invocation (see UpgradeWithHistoryTest). -test-e2e: test-e2e-main test-e2e-upgrade +test-e2e: test-e2e-main test-e2e-upgrade test-e2e-sync test-e2e-main: ./gradlew connectedStandardDebugAndroidTest --stacktrace \ - -Pandroid.testInstrumentationRunnerArguments.notClass=net.activitywatch.android.UpgradeWithHistoryTest + -Pandroid.testInstrumentationRunnerArguments.notClass=net.activitywatch.android.UpgradeWithHistoryTest,net.activitywatch.android.ColdStartSyncTest test-e2e-upgrade: ./gradlew connectedStandardDebugAndroidTest --stacktrace \ -Pandroid.testInstrumentationRunnerArguments.class=net.activitywatch.android.UpgradeWithHistoryTest +# An unstopped app can receive deferred BOOT_COMPLETED on Android 16, starting +# BackgroundService before this test. Disable only that receiver for the run; +# restore its default state even when Gradle/test execution fails. +test-e2e-sync: + ./gradlew installStandardDebug installStandardDebugAndroidTest --stacktrace + @set -eu; \ + pkg=net.activitywatch.android.debug; \ + receiver=$$pkg/net.activitywatch.android.watcher.AlarmReceiver; \ + adb shell run-as $$pkg pm disable $$receiver; \ + trap 'adb shell run-as "$$pkg" pm default-state "$$receiver"' EXIT; \ + adb shell am force-stop $$pkg; \ + ./gradlew connectedStandardDebugAndroidTest --stacktrace \ + -Pandroid.testInstrumentationRunnerArguments.class=net.activitywatch.android.ColdStartSyncTest + test-e2e-screenshot-only: @# To only run screenshot test: ./gradlew connectedStandardDebugAndroidTest \ diff --git a/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt new file mode 100644 index 00000000..9e593727 --- /dev/null +++ b/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt @@ -0,0 +1,57 @@ +package net.activitywatch.android + +import android.content.Context +import android.app.ActivityManager +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import net.activitywatch.android.workers.SyncWorker +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.net.Socket +import java.util.concurrent.TimeUnit + +/** Run in a fresh process; instrumentation does not launch BackgroundService or MainActivity. */ +@RunWith(AndroidJUnit4::class) +class ColdStartSyncTest { + @Test fun workerRecoversLocalServerWithoutOpeningApp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val args = InstrumentationRegistry.getArguments() + if (args.getString("configure") == "true") { + // Instrumentation-only setup, not a production settings/import backdoor. + context.getSharedPreferences(AWPreferences.PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit().putBoolean("isFirstTime", false).putBoolean("syncEnabled", true) + .putBoolean("hasRequestedNotificationPermission", true).commit() + } + val prefs = AWPreferences(context) + prefs.setHandlerPassAt(0) + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + fun foregroundServerRunning() = activityManager.getRunningServices(Int.MAX_VALUE).any { + it.service.className == BackgroundService::class.java.name + } + assertFalse("Foreground service must not assist cold sync", foregroundServerRunning()) + val wasListening = try { Socket("127.0.0.1", BuildConfig.SERVER_PORT).use { true } } + catch (_: java.io.IOException) { false } + assertFalse("Test must start with local server absent", wasListening) + val work = OneTimeWorkRequestBuilder().build() + val manager = WorkManager.getInstance(context) + manager.enqueue(work).result.get(10, TimeUnit.SECONDS) + val deadline = SystemClock.elapsedRealtime() + 90_000 + var state: WorkInfo.State + do { + state = manager.getWorkInfoById(work.id).get(5, TimeUnit.SECONDS).state + if (state.isFinished) break + SystemClock.sleep(200) + } while (SystemClock.elapsedRealtime() < deadline) + assertEquals("Cold sync must finish successfully", WorkInfo.State.SUCCEEDED, state) + assertTrue(prefs.getLastSyncStatus()?.success == true) + assertFalse("Worker must recover without a foreground service", foregroundServerRunning()) + val syncDir = File(context.getExternalFilesDir(null), "sync") + assertTrue("Sync must produce a database", syncDir.walkTopDown().any { it.extension == "db" && it.length() > 0 }) + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt index c0eca86c..80e59be2 100644 --- a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt +++ b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt @@ -14,7 +14,6 @@ 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 @@ -157,7 +156,7 @@ class BackgroundService : Service() { Log.i(TAG, "Service destroyed while the server start was waiting; skipping") return@launch } - migrateSanitizedSyncFolders() + migrateSyncFoldersBeforeServer(this@BackgroundService) // 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. @@ -214,35 +213,6 @@ class BackgroundService : Service() { return BACKGROUND_SERVICE_RESTART_MODE } - /** - * Fold leftover sync folders named after legacy (unsanitized) hostnames into the - * current one. Bucket hostnames in sqlite.db are rewritten by [DatastoreStartup]. - */ - private fun migrateSanitizedSyncFolders() { - 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", - ) - } - } - } - private fun awaitRustInterface(): RustInterface? = try { rustInterface.await() diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index b5052d23..26e9313d 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -374,6 +374,9 @@ class SyncInterface(context: Context) { // (counts/warnings) instead of erasing what the pass actually did. var nativeStatus: SyncStatus? = null try { + // Alarm workers can recreate the process without BackgroundService. + // Start and probe the local HTTP server before JNI tries to pull/push. + ensureSyncServer(appContext) ensureLegacyFoldersMigrated() val response = syncFn() val status = SyncStatus.fromJniResponse(response, System.currentTimeMillis()) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncServer.kt b/mobile/src/main/java/net/activitywatch/android/SyncServer.kt new file mode 100644 index 00000000..5196829c --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/SyncServer.kt @@ -0,0 +1,51 @@ +package net.activitywatch.android + +import android.content.Context +import android.util.Log +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** Run on the sync executor, whose lifetime is owned by WorkManager on cold starts. */ +internal fun ensureSyncServer(context: Context) { + // Match the foreground path: configure auth before Rust reads config.toml. + val apiKey = synchronized(BackgroundService.sanitizedMigrationLock) { + ensureDashboardApiKey(context) + } + val url = URL(buildDashboardUrl("http://127.0.0.1:${BuildConfig.SERVER_PORT}/api/0/info", apiKey)) + awaitSyncServer( + start = { + val rust = RustInterface(context) + synchronized(BackgroundService.sanitizedMigrationLock) { + migrateSyncFoldersBeforeServer(context) + rust.startServerTask() + } + }, + ready = { + val connection = url.openConnection() as HttpURLConnection + try { + connection.connectTimeout = 500 + connection.readTimeout = 500 + connection.responseCode == HttpURLConnection.HTTP_OK + } catch (_: java.io.IOException) { + false + } finally { + connection.disconnect() + } + }, + ) + Log.i("SyncInterface", "Local server ready for sync on port ${BuildConfig.SERVER_PORT}") +} + +/** Called under the same lock on both foreground and worker startup paths. */ +internal fun migrateSyncFoldersBeforeServer(context: Context) { + val syncDir = existingAwSyncDirectory(context) ?: return + val deviceId = File(context.filesDir, "device_id") + .takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } + val result = SanitizedHostnameMigration.migrateSyncFolders( + syncDir, deviceHostname(context), legacyDeviceHostnames(context), deviceId, + ) + if (result.failed > 0) { + Log.w("SyncInterface", "${result.failed} sync-folder migration action(s) failed; retry on next start") + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt b/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt new file mode 100644 index 00000000..550ed744 --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt @@ -0,0 +1,21 @@ +package net.activitywatch.android + +import java.io.IOException + +/** Shared by foreground and alarm sync; never treat a queued server start as readiness. */ +internal fun awaitSyncServer( + start: () -> Unit, + ready: () -> Boolean, + now: () -> Long = { System.nanoTime() / 1_000_000 }, + pause: (Long) -> Unit = { Thread.sleep(it) }, + timeoutMs: Long = 30_000, +) { + if (ready()) return + val deadline = now() + timeoutMs + start() + while (!ready()) { + val remaining = deadline - now() + if (remaining <= 0) throw IOException("Local ActivityWatch server did not become ready within ${timeoutMs}ms") + pause(minOf(100, remaining)) + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt b/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt index 51f95f99..13e22ef5 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt @@ -47,7 +47,7 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c continuation.resume(Result.success()) } else { Log.w(TAG, "Automatic sync failed: $message") - continuation.resume(Result.failure()) + continuation.resume(if (runAttemptCount < 2) Result.retry() else Result.failure()) } } } catch (e: UnsatisfiedLinkError) { diff --git a/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt b/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt new file mode 100644 index 00000000..d18ef05f --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt @@ -0,0 +1,40 @@ +package net.activitywatch.android + +import java.io.IOException +import org.junit.Assert.* +import org.junit.Test + +class SyncServerReadinessTest { + @Test fun existingServerNeedsNoRestart() { + awaitSyncServer(start = { fail("Healthy server must not restart") }, ready = { true }) + } + + @Test fun coldStartWaitsForServerBeforeReturning() { + var time = 0L + var starts = 0 + awaitSyncServer( + start = { starts++ }, ready = { starts > 0 && time >= 300 }, + now = { time }, pause = { time += it }, + ) + assertEquals(1, starts) + assertEquals(300, time) + } + + @Test fun failedStartupCannotProceedToSync() { + var time = 0L + try { + awaitSyncServer(start = {}, ready = { false }, now = { time }, + pause = { time += it }, timeoutMs = 250) + fail("Dead server must fail before native sync") + } catch (_: IOException) { + assertEquals(250, time) + } + } + + @Test fun cancellationStopsReadinessWait() { + try { + awaitSyncServer(start = {}, ready = { false }, pause = { throw InterruptedException() }) + fail("Cancellation must propagate") + } catch (_: InterruptedException) { } + } +} From bb364bb81283f67b9081e11de11e6ba1a9b81ac2 Mon Sep 17 00:00:00 2001 From: Giovanni Francesco Comune Date: Thu, 22 Oct 2026 20:53:29 +0200 Subject: [PATCH 2/2] fix(sync): respect disabled retries and contain native startup errors --- mobile/build.gradle | 1 + .../android/ColdStartSyncTest.kt | 2 + .../android/SyncWorkerDisabledTest.kt | 39 +++++++++++++++++++ .../activitywatch/android/RustInterface.kt | 6 +++ .../android/SyncServerReadiness.kt | 9 ++++- .../android/workers/SyncWorker.kt | 9 ++++- .../android/SyncServerReadinessTest.kt | 11 ++++++ 7 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 mobile/src/androidTest/java/net/activitywatch/android/SyncWorkerDisabledTest.kt diff --git a/mobile/build.gradle b/mobile/build.gradle index 7229a3d9..e47c181d 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -154,6 +154,7 @@ dependencies { // WorkManager implementation "androidx.work:work-runtime-ktx:2.9.1" + androidTestImplementation "androidx.work:work-testing:2.9.1" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' } diff --git a/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt index 9e593727..79b4eac0 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/ColdStartSyncTest.kt @@ -29,6 +29,8 @@ class ColdStartSyncTest { .putBoolean("hasRequestedNotificationPermission", true).commit() } val prefs = AWPreferences(context) + // This test exercises opted-in background work, including on fresh CI installs. + prefs.setSyncEnabled(true) prefs.setHandlerPassAt(0) val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager fun foregroundServerRunning() = activityManager.getRunningServices(Int.MAX_VALUE).any { diff --git a/mobile/src/androidTest/java/net/activitywatch/android/SyncWorkerDisabledTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/SyncWorkerDisabledTest.kt new file mode 100644 index 00000000..e224834f --- /dev/null +++ b/mobile/src/androidTest/java/net/activitywatch/android/SyncWorkerDisabledTest.kt @@ -0,0 +1,39 @@ +package net.activitywatch.android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import kotlinx.coroutines.runBlocking +import net.activitywatch.android.workers.SyncWorker +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncWorkerDisabledTest { + @Test fun queuedAttemptSkipsSyncAfterDisable() = checkDisabledAttempt(0) + + @Test fun pendingRetrySkipsSyncAfterDisable() = checkDisabledAttempt(1) + + private fun checkDisabledAttempt(attempt: Int) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prefs = AWPreferences(context) + val wasEnabled = prefs.isSyncEnabled() + val lastHandlerPass = prefs.getHandlerPassAt() + val lastStatus = prefs.getLastSyncStatus() + try { + prefs.setSyncEnabled(false) + // Avoid masking the missing opt-in check with the recent-handler guard. + prefs.setHandlerPassAt(0) + val worker = TestListenableWorkerBuilder(context) + .setRunAttemptCount(attempt) + .build() + assertEquals(ListenableWorker.Result.success(), runBlocking { worker.doWork() }) + assertEquals("Disabled attempts must not perform a sync", lastStatus, prefs.getLastSyncStatus()) + } finally { + prefs.setSyncEnabled(wasEnabled) + prefs.setHandlerPassAt(lastHandlerPass ?: 0) + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt index 852af767..6ab8aa4f 100644 --- a/mobile/src/main/java/net/activitywatch/android/RustInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/RustInterface.kt @@ -224,7 +224,13 @@ class RustInterface(context: Context? = null) { // Start server Log.w(TAG, "Starting server on port ${BuildConfig.SERVER_PORT}...") startServer(BuildConfig.SERVER_PORT) + } catch (e: UnsatisfiedLinkError) { + // startServer itself is another JNI call, on this separate + // executor. Keep a missing symbol from killing the process; + // sync's bounded readiness wait will report the failed startup. + Log.e(TAG, "Local server native entry point unavailable", e) } finally { + executor.shutdown() // Reset unconditionally, including when startServer() throws // (JNI/native failure): otherwise serverStarted stays true and // startServerTask() never restarts the server until process death. diff --git a/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt b/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt index 550ed744..f2c15625 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncServerReadiness.kt @@ -12,7 +12,14 @@ internal fun awaitSyncServer( ) { if (ready()) return val deadline = now() + timeoutMs - start() + try { + start() + } catch (e: UnsatisfiedLinkError) { + // RustInterface is constructed on the sync executor. Linkage errors are + // Errors, so normalize this recoverable startup failure for its existing + // Exception handler, which persists failure and completes the callback. + throw IOException("Local ActivityWatch server native library unavailable: ${e.message}", e) + } while (!ready()) { val remaining = deadline - now() if (remaining <= 0) throw IOException("Local ActivityWatch server did not become ready within ${timeoutMs}ms") diff --git a/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt b/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt index 13e22ef5..e26821d4 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt @@ -16,9 +16,16 @@ private const val TAG = "SyncWorker" /** Keeps alarm-triggered sync and SAF mirroring inside WorkManager's process lifecycle. */ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { + // Each retry is a new attempt. Sync may have been disabled since the alarm + // was delivered or the previous attempt failed; do not initialize JNI then. + val prefs = AWPreferences(applicationContext) + if (!prefs.isSyncEnabled()) { + Log.i(TAG, "Sync is disabled; skipping queued background sync") + return Result.success() + } // Checked again here, not only when the alarm was delivered: a worker can start // after a Handler pass that began or finished in between, and would then repeat it. - val lastHandlerPassAt = AWPreferences(applicationContext).getHandlerPassAt() + val lastHandlerPassAt = prefs.getHandlerPassAt() if (alarmSyncIsRedundant(lastHandlerPassAt, System.currentTimeMillis())) { Log.i(TAG, "Scheduler synced since the alarm; skipping fallback sync") return Result.success() diff --git a/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt b/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt index d18ef05f..b0ec4e38 100644 --- a/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/SyncServerReadinessTest.kt @@ -5,6 +5,17 @@ import org.junit.Assert.* import org.junit.Test class SyncServerReadinessTest { + @Test fun missingNativeLibraryBecomesReportableStartupFailure() { + val nativeError = UnsatisfiedLinkError("aw_server unavailable") + try { + awaitSyncServer(start = { throw nativeError }, ready = { false }) + fail("Native loading failure must not proceed to sync") + } catch (e: IOException) { + assertSame(nativeError, e.cause) + assertTrue(e.message!!.contains("native library unavailable")) + } + } + @Test fun existingServerNeedsNoRestart() { awaitSyncServer(start = { fail("Healthy server must not restart") }, ready = { true }) }