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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ class AWPreferences(context: Context) {
.apply()
}

// Per hostname, since staging lives under {sync_dir}/{hostname}/ (see StagingReset).
fun isStagingResetDone(hostname: String): Boolean {
return sharedPreferences.getBoolean(StagingReset.prefKey(hostname), false)
}

fun setStagingResetDone(hostname: String) {
sharedPreferences.edit().putBoolean(StagingReset.prefKey(hostname), true).apply()
}

fun hasRequestedNotificationPermission(): Boolean {
return sharedPreferences.getBoolean("hasRequestedNotificationPermission", false)
}
Expand Down
49 changes: 49 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/StagingReset.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package net.activitywatch.android

import org.json.JSONObject

/**
* One-shot reset of this device's push staging db (ActivityWatch/aw-android#296).
*
* Pushes made before ActivityWatch/aw-server-rust#713 left duplicate copies in
* `{sync_dir}/{hostname}/{device_id}/test.db`. Staging is derived data, so the
* native side deletes it and the next push rebuilds it from the local datastore.
*
* Kept free of Android types so the flag logic is unit-testable on the JVM.
*/
object StagingReset {
fun prefKey(hostname: String) = "stagingResetDone_$hostname"

sealed class Result {
object AlreadyDone : Result()
object Done : Result()
data class Failed(val reason: String) : Result()
}

/**
* Runs [reset] unless [isDone], and calls [markDone] only when the native
* side reports `{"success": true}`. Never throws: a failed reset must not
* block the sync that follows it.
*
* [LinkageError] is caught as well as [Exception] because a native library
* built without `resetStaging` throws [UnsatisfiedLinkError]; letting that
* escape would kill the sync executor before its callback clears
* `syncInFlight`, rejecting every later sync as "already in flight".
*/
fun runOnce(isDone: () -> Boolean, markDone: () -> Unit, reset: () -> String): Result {
if (isDone()) return Result.AlreadyDone
return try {
val json = JSONObject(reset())
if (json.optBoolean("success", false)) {
markDone()
Result.Done
} else {
Result.Failed(json.optString("error", "unknown error"))
}
} catch (e: Exception) {
Comment thread
TimeToBuildBob marked this conversation as resolved.
Result.Failed("${e.javaClass.simpleName}: ${e.message}")
} catch (e: LinkageError) {
Result.Failed("${e.javaClass.simpleName}: ${e.message}")
}
}
}
18 changes: 18 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/SyncInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,26 @@ class SyncInterface(context: Context) {
private external fun syncPull(port: Int, hostname: String): String
private external fun syncPush(port: Int, hostname: String): String
private external fun syncBoth(port: Int, hostname: String): String
private external fun resetStaging(hostname: String): String
external fun getSyncDir(): String

private fun getDeviceName(): String = deviceHostname(appContext)

// Must run on the sync executor while syncInFlight is held, so it can never
// race a push writing the same staging db.
private fun resetStagingOnce(hostname: String) {
val prefs = AWPreferences(appContext)
when (val result = StagingReset.runOnce(
isDone = { prefs.isStagingResetDone(hostname) },
markDone = { prefs.setStagingResetDone(hostname) },
reset = { resetStaging(hostname) },
)) {
StagingReset.Result.AlreadyDone -> Unit
StagingReset.Result.Done -> Log.i(TAG, "Staging reset done for $hostname")
is StagingReset.Result.Failed ->
Log.w(TAG, "Staging reset failed for $hostname, will retry next sync: ${result.reason}")
}
}

// Async wrapper for syncPullAll
fun syncPullAllAsync(callback: (Boolean, String) -> Unit) {
Expand Down Expand Up @@ -256,6 +273,7 @@ class SyncInterface(context: Context) {
},
mirrorBeforeCallback
) {
resetStagingOnce(hostname)
syncBoth(BuildConfig.SERVER_PORT, hostname)
}
}
Expand Down
72 changes: 72 additions & 0 deletions mobile/src/test/java/net/activitywatch/android/StagingResetTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package net.activitywatch.android

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class StagingResetTest {
private class Prefs(var done: Boolean = false) {
var markCalls = 0
fun mark() {
markCalls++
done = true
}
}

private fun attempt(prefs: Prefs, reset: () -> String) =
StagingReset.runOnce(isDone = { prefs.done }, markDone = prefs::mark, reset = reset)

@Test
fun runsResetOnceThenSkips() {
val prefs = Prefs()
var resetCalls = 0
val reset = { resetCalls++; """{"success": true}""" }

assertEquals(StagingReset.Result.Done, attempt(prefs, reset))
assertEquals(StagingReset.Result.AlreadyDone, attempt(prefs, reset))

assertEquals(1, resetCalls)
assertEquals(1, prefs.markCalls)
}

@Test
fun alreadyDoneNeverCallsNative() {
val prefs = Prefs(done = true)
val result = attempt(prefs) { throw AssertionError("reset must not run") }
assertEquals(StagingReset.Result.AlreadyDone, result)
assertEquals(0, prefs.markCalls)
}

@Test
fun nativeFailureLeavesFlagUnsetForRetry() {
val prefs = Prefs()
val result = attempt(prefs) { """{"success": false, "error": "path outside sync dir"}""" }
assertEquals(StagingReset.Result.Failed("path outside sync dir"), result)
assertEquals(0, prefs.markCalls)
}

@Test
fun malformedResponseIsFailure() {
val prefs = Prefs()
assertTrue(attempt(prefs) { "not json" } is StagingReset.Result.Failed)
assertTrue(attempt(prefs) { "{}" } is StagingReset.Result.Failed)
assertEquals(0, prefs.markCalls)
}

@Test
fun missingNativeSymbolDoesNotEscape() {
// A native lib built before resetStaging existed throws UnsatisfiedLinkError,
// which is not an Exception; it must not escape into the sync executor.
val prefs = Prefs()
val result = attempt(prefs) { throw UnsatisfiedLinkError("resetStaging") }
assertTrue(result is StagingReset.Result.Failed)
assertEquals(0, prefs.markCalls)
}

@Test
fun prefKeyIsPerHostname() {
assertEquals("stagingResetDone_poco_f8_ultra", StagingReset.prefKey("poco_f8_ultra"))
assertNotEquals(StagingReset.prefKey("a"), StagingReset.prefKey("b"))
}
}
Loading