diff --git a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
index 83fac3ca..6c4ce7ff 100644
--- a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
+++ b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
@@ -155,6 +155,18 @@ class AWPreferences(context: Context) {
)
}
+ // When the SyncScheduler registers the next run (on start or after each completed sync),
+ // it records the epoch-ms of that run here so the UI can display the actual scheduled time
+ // rather than computing it from lastCompletedAt + interval (which diverges after restarts).
+ // Returns 0L if no scheduled time has been recorded yet.
+ fun getSchedulerNextRunAt(): Long {
+ return sharedPreferences.getLong("schedulerNextRunAt", 0L)
+ }
+
+ fun setSchedulerNextRunAt(epochMs: Long) {
+ sharedPreferences.edit().putLong("schedulerNextRunAt", epochMs).apply()
+ }
+
// Dashboard authentication. Defaults to true so first-run gets a key generated
// automatically. Set to false when the user explicitly disables auth in settings;
// ensureDashboardApiKey() checks this before generating a new key so that the
diff --git a/mobile/src/main/java/net/activitywatch/android/SyncScheduler.kt b/mobile/src/main/java/net/activitywatch/android/SyncScheduler.kt
index e396203b..430dfd3d 100644
--- a/mobile/src/main/java/net/activitywatch/android/SyncScheduler.kt
+++ b/mobile/src/main/java/net/activitywatch/android/SyncScheduler.kt
@@ -12,11 +12,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private const val TAG = "SyncScheduler"
-private const val SYNC_INTERVAL_MS = 15 * 60 * 1000L
+// internal (not private): SyncSettingsActivity reads this to render "next sync at" without
+// duplicating the interval or requiring a data-model change.
+internal const val SYNC_INTERVAL_MS = 15 * 60 * 1000L
private const val ACTION_SYNC_ALARM = "net.activitywatch.android.SYNC_ALARM"
class SyncScheduler(private val context: Context) {
private val handler = Handler(Looper.getMainLooper())
+ private val prefs = AWPreferences(context)
private lateinit var syncInterface: SyncInterface
private var isRunning = false
@@ -46,7 +49,9 @@ class SyncScheduler(private val context: Context) {
syncInterface = SyncInterface(context)
// Handler and AlarmManager calls are thread-safe; post from IO is fine.
+ val firstRunAt = System.currentTimeMillis() + 60 * 1000L
handler.postDelayed(syncRunnable, 60 * 1000L)
+ prefs.setSchedulerNextRunAt(firstRunAt)
scheduleAlarm()
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "aw-sync native library unavailable; sync scheduler disabled", e)
@@ -100,8 +105,10 @@ class SyncScheduler(private val context: Context) {
}
// Schedule next sync only after this one completes, preventing overlapping JNI calls.
if (isRunning) {
+ val nextRunAt = System.currentTimeMillis() + SYNC_INTERVAL_MS
Log.i(TAG, "Scheduling next sync in 15 minutes")
handler.postDelayed(syncRunnable, SYNC_INTERVAL_MS)
+ prefs.setSchedulerNextRunAt(nextRunAt)
}
}
}
diff --git a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt
index dace8df5..6614e440 100644
--- a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt
+++ b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt
@@ -7,6 +7,8 @@ import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.provider.DocumentsContract
import android.util.Log
import android.view.MenuItem
@@ -23,6 +25,12 @@ import java.util.Date
private const val TAG = "SyncSettingsActivity"
+// While the screen is visible, "Next sync" is otherwise only refreshed by an explicit event
+// (switch toggle, completed-sync broadcast). Without a periodic tick, a displayed deadline that
+// passes while the user is looking at the screen stays stuck showing the old timestamp instead
+// of flipping to "due now".
+private const val NEXT_SYNC_REFRESH_INTERVAL_MS = 30 * 1000L
+
internal fun formatSyncStatus(status: SyncStatus?, dateFormat: DateFormat): String {
if (status == null) return "Last sync: never"
@@ -43,6 +51,30 @@ internal fun formatSyncStatus(status: SyncStatus?, dateFormat: DateFormat): Stri
return "$headline\n${formatSyncDetail(status)}"
}
+/**
+ * "When will it sync next?" — uses the scheduler's own recorded next-run time
+ * (written by SyncScheduler.start() and after each completed sync) so the displayed
+ * time matches what the scheduler actually has registered. Falls back to
+ * lastCompletedAt + SYNC_INTERVAL_MS when no scheduler time is recorded (e.g. after
+ * a fresh install before the first start() call).
+ */
+internal fun formatNextSyncStatus(
+ enabled: Boolean,
+ lastStatus: SyncStatus?,
+ dateFormat: DateFormat,
+ now: Long = System.currentTimeMillis(),
+ schedulerNextRunAt: Long? = null,
+): String {
+ if (!enabled) return "Next sync: sync is disabled"
+ if (lastStatus == null) {
+ return "Next sync: shortly (first sync runs about a minute after ActivityWatch starts)"
+ }
+ val nextAt = schedulerNextRunAt?.takeIf { it > 0L }
+ ?: (lastStatus.completedAt + SYNC_INTERVAL_MS)
+ if (nextAt <= now) return "Next sync: due now"
+ return "Next sync: ${dateFormat.format(Date(nextAt))}"
+}
+
/**
* The per-run facts line: what moved and which peers it came from. Without
* this, a pass that transferred nothing is indistinguishable from one that
@@ -69,6 +101,7 @@ class SyncSettingsActivity : AppCompatActivity() {
private lateinit var switchSyncEnabled: SwitchCompat
private lateinit var tvSyncDirStatus: TextView
private lateinit var tvLastSyncStatus: TextView
+ private lateinit var tvNextSyncStatus: TextView
private lateinit var btnChooseDir: Button
// Guards against the switch listener firing when we set isChecked programmatically
@@ -78,10 +111,19 @@ class SyncSettingsActivity : AppCompatActivity() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AWPreferences.LAST_SYNC_STATUS_CHANGED_ACTION) {
updateLastSyncStatus()
+ updateNextSyncStatus()
}
}
}
+ private val nextSyncRefreshHandler = Handler(Looper.getMainLooper())
+ private val nextSyncRefreshRunnable = object : Runnable {
+ override fun run() {
+ updateNextSyncStatus()
+ nextSyncRefreshHandler.postDelayed(this, NEXT_SYNC_REFRESH_INTERVAL_MS)
+ }
+ }
+
private val openDocumentTree =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
@@ -147,6 +189,7 @@ class SyncSettingsActivity : AppCompatActivity() {
switchSyncEnabled = findViewById(R.id.switch_sync_enabled)
tvSyncDirStatus = findViewById(R.id.tv_sync_dir_status)
tvLastSyncStatus = findViewById(R.id.tv_last_sync_status)
+ tvNextSyncStatus = findViewById(R.id.tv_next_sync_status)
btnChooseDir = findViewById(R.id.btn_choose_sync_dir)
refreshUI()
@@ -160,6 +203,7 @@ class SyncSettingsActivity : AppCompatActivity() {
action = BackgroundService.ACTION_SYNC_ENABLED_CHANGED
putExtra(BackgroundService.EXTRA_START_ORIGIN, BackgroundService.START_ORIGIN_SETTINGS)
})
+ updateNextSyncStatus()
}
btnChooseDir.setOnClickListener {
@@ -179,6 +223,8 @@ class SyncSettingsActivity : AppCompatActivity() {
IntentFilter(AWPreferences.LAST_SYNC_STATUS_CHANGED_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
+ nextSyncRefreshHandler.removeCallbacks(nextSyncRefreshRunnable)
+ nextSyncRefreshHandler.postDelayed(nextSyncRefreshRunnable, NEXT_SYNC_REFRESH_INTERVAL_MS)
}
override fun onResume() {
@@ -188,6 +234,7 @@ class SyncSettingsActivity : AppCompatActivity() {
override fun onStop() {
unregisterReceiver(syncStatusReceiver)
+ nextSyncRefreshHandler.removeCallbacks(nextSyncRefreshRunnable)
super.onStop()
}
@@ -197,6 +244,7 @@ class SyncSettingsActivity : AppCompatActivity() {
isUpdatingSwitch = false
updateSyncDirStatus()
updateLastSyncStatus()
+ updateNextSyncStatus()
}
private fun updateLastSyncStatus() {
@@ -206,6 +254,15 @@ class SyncSettingsActivity : AppCompatActivity() {
)
}
+ private fun updateNextSyncStatus() {
+ tvNextSyncStatus.text = formatNextSyncStatus(
+ prefs.isSyncEnabled(),
+ prefs.getLastSyncStatus(),
+ combinedDateTimeFormat(),
+ schedulerNextRunAt = prefs.getSchedulerNextRunAt().takeIf { it > 0L },
+ )
+ }
+
private fun combinedDateTimeFormat(): DateFormat {
val dateFormat = android.text.format.DateFormat.getMediumDateFormat(this)
val timeFormat = android.text.format.DateFormat.getTimeFormat(this)
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 14d4058d..38488c6e 100644
--- a/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt
+++ b/mobile/src/main/java/net/activitywatch/android/workers/SyncWorker.kt
@@ -5,6 +5,8 @@ import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.suspendCancellableCoroutine
+import net.activitywatch.android.AWPreferences
+import net.activitywatch.android.SYNC_INTERVAL_MS
import net.activitywatch.android.SyncInterface
import kotlin.coroutines.resume
@@ -25,6 +27,13 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
syncInterface.syncBothAndMirrorAsync { success, message ->
if (!continuation.isActive) return@syncBothAndMirrorAsync
+ // The alarm-triggered path runs independently of SyncScheduler (whose in-process
+ // Handler chain may be dead after a process kill) — re-anchor here too, or the
+ // "Next sync" display gets stuck showing a stale/past time forever after a restart.
+ AWPreferences(applicationContext).setSchedulerNextRunAt(
+ System.currentTimeMillis() + SYNC_INTERVAL_MS
+ )
+
if (success) {
Log.i(TAG, "Automatic sync completed successfully: $message")
continuation.resume(Result.success())
diff --git a/mobile/src/main/res/layout/activity_sync_settings.xml b/mobile/src/main/res/layout/activity_sync_settings.xml
index 7cdd1204..4bc95d70 100644
--- a/mobile/src/main/res/layout/activity_sync_settings.xml
+++ b/mobile/src/main/res/layout/activity_sync_settings.xml
@@ -47,11 +47,21 @@
android:id="@+id/tv_last_sync_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="16dp"
+ android:layout_marginBottom="8dp"
android:text="Last sync: never"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary" />
+
+
+