Skip to content
Open
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
18 changes: 16 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions mobile/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
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
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
51 changes: 51 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/SyncServer.kt
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Server library errors escape

If aw_server cannot load or lacks a required JNI method, the new RustInterface(context) call throws UnsatisfiedLinkError on the sync executor. Its handler catches only Exception. The handler in SyncWorker cannot 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

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
Expand Up @@ -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()
Expand Down Expand Up @@ -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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sync runs after being disabled

If the user turns sync off while a retry is pending, that retry can still pull, push, and copy files. The new retry starts doWork() again, but that method checks only recent scheduler activity, not isSyncEnabled(). Turning sync off stops the scheduler without cancelling queued workers.

Check isSyncEnabled() at the start of each attempt so retries respect the user's choice.

Knowledge Base Used:

}
}
} catch (e: UnsatisfiedLinkError) {
Expand Down
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) { }
}
}