fuzzyworld

haunt / commit

Restore light state after SIGKILL mid-blink

894372430fa0401ede4a7d6db9bdbb5c313940fb · Pavel Safronov · 2026-05-13 14:47:30 -0700 · parents 377b0079b712

Restore light state after SIGKILL mid-blink

Before starting the first blink, persist three keys to SharedPreferences:
  blink_restore_id, blink_restore_was_on, blink_restore_is_group

The finally block clears them after a normal exit (user moves away or
disables haunt). If the keys are still present when onStartCommand fires
(START_STICKY restart after SIGKILL), the process died while the light was
mid-blink — restore it immediately before starting the haunt loop.

The prefs key is removed before the restore coroutine runs so a second
rapid restart cannot double-fire the restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../main/java/net/fuzzyworld/haunt/HauntService.kt | 38 ++++++++++++++++++++++
 1 file changed, 38 insertions(+)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt b/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt
index 734036a..755a6b7 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt
@@ -7,6 +7,7 @@ import android.app.PendingIntent
 import android.app.Service
 import android.content.Context
 import android.content.Intent
+import android.content.SharedPreferences
 import android.os.IBinder
 import android.os.PowerManager
 import androidx.core.app.NotificationCompat
@@ -34,9 +35,11 @@ class HauntService : Service() {
     private val job = SupervisorJob()
     private val scope = CoroutineScope(Dispatchers.Default + job)
     private var wakeLock: PowerManager.WakeLock? = null
+    private lateinit var prefs: SharedPreferences
 
     override fun onCreate() {
         super.onCreate()
+        prefs = getSharedPreferences("haunt_prefs", Context.MODE_PRIVATE)
         createNotificationChannel()
     }
 
@@ -45,6 +48,26 @@ class HauntService : Service() {
         wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
             .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "haunt:ranging")
             .also { it.acquire(12 * 60 * 60 * 1000L) }
+
+        // If the service was killed mid-blink, a pending restore entry will be in prefs.
+        // Clear it immediately (so a second restart doesn't try twice) then do the restore.
+        val pendingId = prefs.getString(PREF_RESTORE_ID, null)
+        if (pendingId != null) {
+            val wasOn   = prefs.getBoolean(PREF_RESTORE_WAS_ON,  false)
+            val isGroup = prefs.getBoolean(PREF_RESTORE_IS_GROUP, false)
+            prefs.edit().remove(PREF_RESTORE_ID).apply()
+            scope.launch(Dispatchers.IO) {
+                try {
+                    val cfg = HueBridgeConfig(this@HauntService)
+                    val ip  = cfg.bridgeIp  ?: return@launch
+                    val u   = cfg.username  ?: return@launch
+                    val restore = LightStateRequest(on = wasOn)
+                    if (isGroup) HueBridgeClient(ip).setGroupState(u, pendingId, restore)
+                    else         HueBridgeClient(ip).setLightState(u, pendingId, restore)
+                } catch (_: Exception) {}
+            }
+        }
+
         scope.launch { runHauntLoop() }
         return START_STICKY
     }
@@ -98,6 +121,13 @@ class HauntService : Service() {
             else client.getLightState(username, point.hueLightId)
         } catch (_: Exception) { false }
 
+        // Persist before the first blink so a SIGKILL mid-cycle can be recovered on restart.
+        prefs.edit()
+            .putString(PREF_RESTORE_ID, point.hueLightId)
+            .putBoolean(PREF_RESTORE_WAS_ON, wasOn)
+            .putBoolean(PREF_RESTORE_IS_GROUP, point.isGroup)
+            .apply()
+
         try {
             while (true) {
                 try {
@@ -127,6 +157,7 @@ class HauntService : Service() {
                     if (point.isGroup) client.setGroupState(username, point.hueLightId, restore)
                     else client.setLightState(username, point.hueLightId, restore)
                 } catch (_: Exception) {}
+                prefs.edit().remove(PREF_RESTORE_ID).apply()
             }
         }
     }
@@ -174,6 +205,13 @@ class HauntService : Service() {
         private const val CHANNEL_ID = "haunt_service"
         private const val HAUNT_THRESHOLD = 2.0
 
+        // Keys used to persist light state across unexpected process death.
+        // Written before the first blink, cleared in the finally block on normal exit.
+        // If still present on next onStartCommand, the process was killed mid-blink.
+        private const val PREF_RESTORE_ID       = "blink_restore_id"
+        private const val PREF_RESTORE_WAS_ON   = "blink_restore_was_on"
+        private const val PREF_RESTORE_IS_GROUP = "blink_restore_is_group"
+
         private val _hauntTarget = MutableStateFlow<String?>(null)
         val hauntTarget: StateFlow<String?> = _hauntTarget