Repository navigation
fix(sync): start local server before background sync #379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| 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) | ||
| // 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 { | ||
| 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<SyncWorker>().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 }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SyncWorker>(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) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| 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 | ||
| 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") | ||
| pause(minOf(100, remaining)) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
|
@@ -47,7 +54,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()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the user turns sync off while a retry is pending, that retry can still pull, push, and copy files. The new retry starts Check Knowledge Base Used: |
||
| } | ||
| } | ||
| } catch (e: UnsatisfiedLinkError) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package net.activitywatch.android | ||
|
|
||
| import java.io.IOException | ||
| 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 }) | ||
| } | ||
|
|
||
| @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) { } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
aw_servercannot load or lacks a required JNI method, the newRustInterface(context)call throwsUnsatisfiedLinkErroron the sync executor. Its handler catches onlyException. The handler inSyncWorkercannot catch errors from this other thread, so cold sync can crash the app instead of reporting failure.Catch this error on the sync executor and report it through the callback.
Knowledge Base Used: Rust interface integration