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" /> + + +