Skip to content
Merged
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
12 changes: 12 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
TimeToBuildBob marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment thread
TimeToBuildBob marked this conversation as resolved.
handler.postDelayed(syncRunnable, 60 * 1000L)
prefs.setSchedulerNextRunAt(firstRunAt)
Comment thread
TimeToBuildBob marked this conversation as resolved.
scheduleAlarm()
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "aw-sync native library unavailable; sync scheduler disabled", e)
Expand Down Expand Up @@ -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
Comment thread
TimeToBuildBob marked this conversation as resolved.
Log.i(TAG, "Scheduling next sync in 15 minutes")
handler.postDelayed(syncRunnable, SYNC_INTERVAL_MS)
prefs.setSchedulerNextRunAt(nextRunAt)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand All @@ -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)"
}
Comment thread
TimeToBuildBob marked this conversation as resolved.
val nextAt = schedulerNextRunAt?.takeIf { it > 0L }
?: (lastStatus.completedAt + SYNC_INTERVAL_MS)
Comment thread
TimeToBuildBob marked this conversation as resolved.
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
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -160,6 +203,7 @@ class SyncSettingsActivity : AppCompatActivity() {
action = BackgroundService.ACTION_SYNC_ENABLED_CHANGED
putExtra(BackgroundService.EXTRA_START_ORIGIN, BackgroundService.START_ORIGIN_SETTINGS)
})
updateNextSyncStatus()
Comment thread
TimeToBuildBob marked this conversation as resolved.
}

btnChooseDir.setOnClickListener {
Expand All @@ -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)
Comment thread
TimeToBuildBob marked this conversation as resolved.
}

override fun onResume() {
Expand All @@ -188,6 +234,7 @@ class SyncSettingsActivity : AppCompatActivity() {

override fun onStop() {
unregisterReceiver(syncStatusReceiver)
nextSyncRefreshHandler.removeCallbacks(nextSyncRefreshRunnable)
super.onStop()
}

Expand All @@ -197,6 +244,7 @@ class SyncSettingsActivity : AppCompatActivity() {
isUpdatingSwitch = false
updateSyncDirStatus()
updateLastSyncStatus()
updateNextSyncStatus()
}

private fun updateLastSyncStatus() {
Expand All @@ -206,6 +254,15 @@ class SyncSettingsActivity : AppCompatActivity() {
)
}

private fun updateNextSyncStatus() {
Comment thread
TimeToBuildBob marked this conversation as resolved.
tvNextSyncStatus.text = formatNextSyncStatus(
prefs.isSyncEnabled(),
prefs.getLastSyncStatus(),
combinedDateTimeFormat(),
schedulerNextRunAt = prefs.getSchedulerNextRunAt().takeIf { it > 0L },
)
Comment thread
TimeToBuildBob marked this conversation as resolved.
}

private fun combinedDateTimeFormat(): DateFormat {
val dateFormat = android.text.format.DateFormat.getMediumDateFormat(this)
val timeFormat = android.text.format.DateFormat.getTimeFormat(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Comment thread
TimeToBuildBob marked this conversation as resolved.
System.currentTimeMillis() + SYNC_INTERVAL_MS
)

if (success) {
Log.i(TAG, "Automatic sync completed successfully: $message")
continuation.resume(Result.success())
Expand Down
12 changes: 11 additions & 1 deletion mobile/src/main/res/layout/activity_sync_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

<!-- Next scheduled automatic sync -->
<TextView
android:id="@+id/tv_next_sync_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Next sync: sync is disabled"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary" />

<!-- Choose directory button -->
<Button
android:id="@+id/btn_choose_sync_dir"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,71 @@ class SyncSettingsActivityTest {
)
}

@Test
fun formatNextSyncStatus_reportsDisabledWhenSyncOff() {
assertEquals(
"Next sync: sync is disabled",
formatNextSyncStatus(enabled = false, lastStatus = null, dateFormat = dateFormat),
)
}

@Test
fun formatNextSyncStatus_reportsShortlyBeforeFirstSync() {
assertEquals(
"Next sync: shortly (first sync runs about a minute after ActivityWatch starts)",
formatNextSyncStatus(enabled = true, lastStatus = null, dateFormat = dateFormat),
)
}

@Test
fun formatNextSyncStatus_addsIntervalToLastCompletedRun() {
// 2026-09-01 01:30:00 UTC + 15 minutes = 2026-09-01 01:45:00 UTC
val completedAt = 1_788_226_200_000L
assertEquals(
"Next sync: 2026-09-01 01:45",
formatNextSyncStatus(
enabled = true,
lastStatus = SyncStatus(completedAt = completedAt, success = true),
dateFormat = dateFormat,
now = completedAt,
),
)
}

@Test
fun formatNextSyncStatus_reportsDueNowWhenIntervalHasElapsed() {
val completedAt = 1_788_226_200_000L
val wellPastInterval = completedAt + 60 * 60 * 1000L
assertEquals(
"Next sync: due now",
formatNextSyncStatus(
enabled = true,
lastStatus = SyncStatus(completedAt = completedAt, success = true),
dateFormat = dateFormat,
now = wellPastInterval,
),
)
}

@Test
fun formatNextSyncStatus_usesSchedulerNextRunAtOverComputedInterval() {
Comment thread
TimeToBuildBob marked this conversation as resolved.
// After a service restart, scheduler schedules first run at +60s, not lastCompletedAt+15min.
// The UI must show the scheduler-registered time, not the computed one.
val completedAt = 1_788_226_200_000L // 2026-09-01 01:30:00 UTC
val now = completedAt + 5 * 1000L // 5s after last sync
val schedulerNextRunAt = now + 60 * 1000L // scheduler registered +60s from restart
assertEquals(
"Next sync: 2026-09-01 01:31",
formatNextSyncStatus(
enabled = true,
lastStatus = SyncStatus(completedAt = completedAt, success = true),
dateFormat = dateFormat,
now = now,
schedulerNextRunAt = schedulerNextRunAt,
),
)
}

@Test
fun normalizeError_capsLength() {
val raw = "x".repeat(SyncStatus.MAX_ERROR_CHARS + 50)
Expand Down
Loading