💡 Executive Summary
The lifeblood of a timer app is unconditional reliability: time must flow accurately and alarms must fire on the exact second whether the screen is locked or the app is swipe-killed. In this final installment (Part 5), we open-source our wall-clock persistence engine (targetEndEpochMs), OS-specific background survival strategies (iOS in-memory silent PCM WAV loops & Android PARTIAL_WAKE_LOCK), and our race-condition-free bidirectional sync protocol spanning iPhone, Apple Watch (WCSession), Android, and Galaxy Watch (Wearable Data Layer).
Unstoppable Timer Persistence and 4-OS Bidirectional Watch Sync
▲ Wall-clock persistence (targetEndEpochMs), OS background survival engines, and 4-device bidirectional real-time synchronization

1. Why Naive Timers Freeze When You Lock the Screen or Kill the App

Tutorial timers typically rely on Timer.periodic(Duration(seconds: 1), (_) => remainingSeconds--). In production mobile environments, that naive approach fails in two ways:

  1. Background Thread Suspension: Within seconds of locking the screen, both iOS and Android suspend the Dart VM event loop to save battery.
  2. State Loss on Swipe-Kill: If the user swipes the app away from the recent apps switcher, in-memory counters evaporate and reset to 25:00 on next launch.

The Solution: Persist the Absolute Target Epoch (targetEndEpochMs), Never Just a Counter

Whenever a session starts, TimerService (lib/service/timer_service.dart) computes the exact future wall-clock timestamp (_targetEndEpochMs = nowMs + remainingSeconds * 1000) and flushes it immediately to SharedPreferences:

// lib/service/timer_service.dart — Wall-clock persistence & swipe-kill recovery
Future<void> _restorePersistedTimerState() async {
  final prefs = await SharedPreferences.getInstance();
  final savedRunning = prefs.getBool('v2_timer_is_running') ?? false;
  final savedTargetEndMs = prefs.getInt('v2_timer_target_end_ms') ?? 0;

  if (savedRunning && savedTargetEndMs > 0) {
    final nowMs = DateTime.now().millisecondsSinceEpoch;
    final diffMs = savedTargetEndMs - nowMs;

    if (diffMs > 0) {
      // Case A: Target time is still in the future -> resume seamlessly with zero drift!
      _remainingSeconds = (diffMs / 1000).ceil();
      _targetEndEpochMs = savedTargetEndMs;
      _isRunning = true;
      _startInternalTicker();
    } else {
      // Case B: Target time passed while the app was killed -> complete & award ticket immediately!
      _remainingSeconds = 0;
      _isRunning = false;
      _handleSessionCompletion(completedWhileBackgrounded: true);
    }
  }
}

With this design, a user can start a 25-minute timer, force-close (swipe kill) the app, and reopen it 10 minutes later to see the timer ticking down from 15:00 with zero drift.

2. Lock-Screen Survival: iOS In-Memory Silent WAV Loop vs. Android WakeLock

Recovering state on reopen is only half the battle. While the phone screen is locked, the alarm and vibration must fire on the exact second, and the companion smartwatch must receive live updates. We built native background engines tailored to each OS kernel:

Capability iOS (AppDelegate.swift) Android (MainActivity.kt)
Background Keep-Alive In-memory 8kHz silent PCM WAV loop (.playback + .mixWithOthers) PowerManager.PARTIAL_WAKE_LOCK + Native Handler.postDelayed
Lock-Screen Alarm UNTimeIntervalNotificationTrigger + AudioServicesPlayAlertSound(1005) RingtoneManager(USAGE_ALARM) + custom VibrationEffect waveform
Watch-Triggered Wakeup Watch calling sendMessage(..., replyHandler:) wakes background iOS app MessageReceiverService & DataClient wake the host process

iOS: Generating a 1-Second Silent PCM WAV in Memory

On iOS, locking the screen suspends standard app processes within seconds, severing live WCSession ticks to the Apple Watch. In AppDelegate.swift, whenever the timer is actively running, we synthesize a tiny 1-second 8kHz silent PCM WAV buffer directly in RAM (at volume 0.0 with .mixWithOthers) and loop it, keeping the process alive without interrupting any external music:

// ios/Runner/AppDelegate.swift — In-memory silent WAV keep-alive during active countdowns
private func updateBackgroundKeepAlive(isRunning: Bool) {
    if isRunning {
        guard !isKeepAliveActive else { return }
        do {
            let audioSession = AVAudioSession.sharedInstance()
            try audioSession.setCategory(.playback, options: [.mixWithOthers])
            try audioSession.setActive(true)
            if silentKeepAlivePlayer == nil {
                silentKeepAlivePlayer = try AVAudioPlayer(data: Self.makeSilentWavData())
                silentKeepAlivePlayer?.numberOfLoops = -1
                silentKeepAlivePlayer?.volume = 0.0
                silentKeepAlivePlayer?.prepareToPlay()
            }
            silentKeepAlivePlayer?.play()
            isKeepAliveActive = true
        } catch {}
    } else {
        guard isKeepAliveActive else { return }
        silentKeepAlivePlayer?.pause()
        isKeepAliveActive = false
    }
}

Android: Partial WakeLock & Native Alarm Handler

On Android, Doze mode throttles CPU timers when the screen turns off. In MainActivity.kt, we acquire a bounded PARTIAL_WAKE_LOCK while a session is running and schedule a native main-thread Handler callback for targetEndMs that triggers an alarm-channel ringtone (AudioAttributes.USAGE_ALARM) and a multi-pulse vibration waveform (0, 400, 200, 400, 200, 600ms).

3. 4-Device Bidirectional Synchronization & Eliminating 'Ping-Pong' Race Conditions

Finally, a user should be able to start a session on their phone, glance at their wrist, and tap Pause (⏸), Resume (▶), Reset (↺), or Skip (⏭) on their Apple Watch or Galaxy Watch to control the phone in their pocket within 100ms.

sequenceDiagram autonumber participant Watch as ⌚ Apple Watch / Galaxy Watch participant Native as 📱 iOS AppDelegate / Android MainActivity participant Dart as 🎯 Flutter TimerService Note over Watch,Dart: [Case 1] User taps Start / Pause / Skip on the Watch Watch->>Watch: 1. Optimistic local UI update
2. Generate UUID actionId & start 1.5s debounce guard Watch->>Native: Dual-Channel Dispatch
• Real-time: sendMessage (with replyHandler)
• Persistent: updateApplicationContext / PutDataMapRequest Native->>Native: Verify idempotency via isNewWatchAction(actionId)
& update WakeLock / silent keep-alive Native->>Dart: MethodChannel.invokeMethod("sync_from_watch", payload) Dart->>Dart: Synchronize targetEndEpochMs & session state! Note over Watch,Dart: [Case 2] Phone app resumes after background watch interaction Dart->>Native: onResume -> invokeMethod("checkPendingWatchSync") Native-->>Dart: Return latest unapplied watch payload (Zero drift)

Engineering Highlight 1: Dual-Channel Dispatch (Real-Time + Persistent State)

Both Apple's WatchConnectivity and Google's Wearable Data Layer API provide two distinct transport mechanisms:

  • Real-Time Messaging (sendMessage / MessageClient): Delivers sub-100ms packets when both devices are reachable (and on iOS, invoking sendMessage with a non-nil replyHandler wakes the companion iPhone app in the background!).
  • State Context (updateApplicationContext / DataClient): Persists the latest state snapshot in the OS daemon so the companion app immediately receives the latest state when launched later.

We send lightweight per-second ticks (isTickOnly: true) exclusively over the real-time channel, while every state transition (Start, Pause, Reset, Skip, Session Complete) writes to both channels simultaneously.

Engineering Highlight 2: Solving the Button 'Ping-Pong' Race Condition

During early testing of bidirectional sync, tapping Pause (⏸) on the watch caused the button to briefly flip back to Running (▶) 200ms later before pausing again—a classic distributed race condition. While the watch's Pause packet was in flight to the phone, an older isRunning: true tick packet sent by the phone 100ms earlier arrived at the watch and overwrote the local state.

We eliminated this race condition completely using two mechanisms across Swift and Kotlin: (1) UUID actionId idempotency tracking in persistent storage, and (2) a 1.5-second local action debounce window that ignores stale in-flight phone packets right after a wrist tap:

// android/wearrico/.../MainActivity.kt — Galaxy Watch (Wear OS) bidirectional sync & race guard
private fun applyPayload(payload: Map<String, Any>) {
    val completed = (payload["sessionCompleted"] as? Boolean) ?: false
    if (completed) {
        WatchDataHolder.playCompletionHaptics(this)
    }
    if (!payload.containsKey("sessionState") && !payload.containsKey("isRunning")) {
        return
    }
    // 🛡️ [1.5s Debounce Guard] Ignore stale in-flight phone packets arriving within 1.5s
    // of a local watch button press to prevent UI ping-pong!
    if (!completed && System.currentTimeMillis() - lastLocalActionMs < 1500L) {
        return
    }
    // ... Apply synchronized state & targetEndEpochMs
}

private fun pushWatchStateToPhone() {
    val nowMs = System.currentTimeMillis()
    lastLocalActionMs = nowMs
    // 🛡️ [Idempotency] Tag every watch command with a unique UUID actionId
    lastActionId = UUID.randomUUID().toString()
    saveLocalWatchState()

    val payloadObj = JSONObject().apply {
        put("command", "sync_from_watch")
        put("actionId", lastActionId)
        put("sessionState", sessionState.value)
        put("isRunning", isRunning.value)
        put("remainingSeconds", remainingSeconds.intValue)
        put("totalSeconds", totalSeconds.intValue)
        put("targetEndEpochMs", targetEndEpochMs.longValue)
        put("updatedAtMs", nowMs)
    }
    val bytes = payloadObj.toString().toByteArray(Charsets.UTF_8)

    // 1. Real-time MessageClient dispatch + 2. Persistent DataClient snapshot
    Wearable.getNodeClient(this).connectedNodes.addOnSuccessListener { nodes ->
        nodes.forEach { node ->
            Wearable.getMessageClient(this).sendMessage(node.id, "/pomodoro-command", bytes)
        }
    }
    val putReq = PutDataMapRequest.create("/pomodoro-watch-sync").apply {
        dataMap.putString("payload_json", payloadObj.toString())
        dataMap.putLong("timestamp", nowMs)
    }.asPutDataRequest().setUrgent()
    Wearable.getDataClient(this).putDataItem(putReq)
}

4. Epilogue: Four Operating Systems, One Seamless Focus Experience

What began a year ago as an abandoned white-screen timer prototype has been reborn in v2.0 as a full-stack cross-platform product:

  • Lo-Fi Focus Studio UI: A warm charcoal, amber, and emerald 4-tab workspace designed for zero eye strain
  • Focus-to-Earn & 4-Layer Security: A behavioral-economics weekly lottery loop backed by opt-in rewarded ads and Supabase PostgreSQL SECURITY DEFINER RPC verification
  • Studio Ambient Audio Engine: 3.1MB of authentic Google Actions Sound Library OGG field recordings powering a 5-channel 24-hour gapless mixer
  • 4-OS Native Integration: Hardware-verified on iPhone (iOS 26.3.1), Apple Watch Series 10 (watchOS 26.2), Android, and Galaxy Watch4 Classic (Wear OS) with sub-100ms bidirectional sync
🚀 Closing Thoughts
Building a demo timer that ticks on an active screen takes an afternoon. Engineering a cross-platform timer that survives OS sleep states, swipe-kills, kernel JIT protections, and bidirectional wrist synchronization across four operating systems is a deeply rewarding systems challenge. I hope this 5-part series serves as a practical reference for fellow engineers building across Flutter, iOS, watchOS, Android, and Wear OS!