fuzzyworld

haunt / commit

Run haunt mode as a foreground service for reliable background operation

377b0079b712ac21ed8ab7b0014cfe9e6be08c3a · Pavel Safronov · 2026-05-13 14:42:36 -0700 · parents 35fda751ff28

Run haunt mode as a foreground service for reliable background operation

When haunt mode is enabled the app now starts HauntService, a foreground
service that keeps the process alive (and the CPU awake via PARTIAL_WAKE_LOCK)
even when the screen is off or the user has switched to another app.

HauntService owns the full haunt loop:
- Runs its own RttRangingManager ranging cycle (2 s interval)
- Nearest-light detection with HAUNT_THRESHOLD = 2.0
- blinkLoop with pre-blink state capture and NonCancellable restore on stop
- Persistent notification ("Haunt active / Near: <light>") that taps back
  to MainActivity; notification text updates as the nearest light changes
- START_STICKY so Android restarts the service if it is killed under
  memory pressure

DebugViewModel no longer contains haunt logic; toggleHauntMode() just
calls HauntService.start/stop. hauntTarget is forwarded from the service's
companion-object StateFlow so the UI still updates correctly.

Manifest additions: FOREGROUND_SERVICE, FOREGROUND_SERVICE_LOCATION (API 34+),
WAKE_LOCK permissions; service declared with foregroundServiceType="location".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 app/src/main/AndroidManifest.xml                   |   9 +
 .../main/java/net/fuzzyworld/haunt/HauntService.kt | 186 +++++++++++++++++++++
 .../net/fuzzyworld/haunt/debug/DebugViewModel.kt   | 100 +----------
 3 files changed, 199 insertions(+), 96 deletions(-)

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d7b7ed9..7cf065b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,10 @@
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
     <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <!-- Required on Android 14+ for foreground services that access location (RTT ranging) -->
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
 
     <uses-feature android:name="android.hardware.wifi.rtt" android:required="true" />
 
@@ -28,6 +32,11 @@
             </intent-filter>
         </activity>
 
+        <service
+            android:name=".HauntService"
+            android:foregroundServiceType="location"
+            android:exported="false" />
+
     </application>
 
 </manifest>
diff --git a/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt b/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt
new file mode 100644
index 0000000..734036a
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/HauntService.kt
@@ -0,0 +1,186 @@
+package net.fuzzyworld.haunt
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.fuzzyworld.haunt.hue.HueBridgeClient
+import net.fuzzyworld.haunt.hue.HueBridgeConfig
+import net.fuzzyworld.haunt.hue.HueLightPoint
+import net.fuzzyworld.haunt.hue.HueLightStore
+import net.fuzzyworld.haunt.hue.LightStateRequest
+import net.fuzzyworld.haunt.ranging.RttRangingManager
+import kotlin.math.sqrt
+
+class HauntService : Service() {
+
+    private val job = SupervisorJob()
+    private val scope = CoroutineScope(Dispatchers.Default + job)
+    private var wakeLock: PowerManager.WakeLock? = null
+
+    override fun onCreate() {
+        super.onCreate()
+        createNotificationChannel()
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        startForeground(NOTIF_ID, buildNotification(null))
+        wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
+            .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "haunt:ranging")
+            .also { it.acquire(12 * 60 * 60 * 1000L) }
+        scope.launch { runHauntLoop() }
+        return START_STICKY
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        _hauntTarget.value = null
+        job.cancel()
+        wakeLock?.release()
+        wakeLock = null
+    }
+
+    override fun onBind(intent: Intent?): IBinder? = null
+
+    private suspend fun runHauntLoop() {
+        val rttManager = RttRangingManager(this)
+        val lightStore = HueLightStore(this)
+        val hueConfig = HueBridgeConfig(this)
+        val lastKnown = mutableMapOf<String, Double>()
+        var blinkJob: Job? = null
+        var currentId: String? = null
+
+        while (true) {
+            val fresh = rttManager.rangeAll()
+            fresh.forEach { lastKnown[it.bssid] = it.distanceMeters }
+
+            val target = nearestLight(lastKnown, lightStore.load())
+
+            if (target?.hueLightId != currentId) {
+                blinkJob?.cancel()
+                blinkJob?.join() // wait for light-state restore to complete before switching
+                currentId = target?.hueLightId
+                _hauntTarget.value = currentId
+                updateNotification(target?.name)
+                if (target != null) {
+                    blinkJob = scope.launch(Dispatchers.IO) { blinkLoop(hueConfig, target) }
+                }
+            }
+
+            delay(2000)
+        }
+    }
+
+    private suspend fun blinkLoop(hueConfig: HueBridgeConfig, point: HueLightPoint) {
+        val ip = hueConfig.bridgeIp ?: return
+        val username = hueConfig.username ?: return
+        val client = HueBridgeClient(ip)
+
+        val wasOn = try {
+            if (point.isGroup) client.getGroupState(username, point.hueLightId)
+            else client.getLightState(username, point.hueLightId)
+        } catch (_: Exception) { false }
+
+        try {
+            while (true) {
+                try {
+                    val on = LightStateRequest(on = true)
+                    val off = LightStateRequest(on = false)
+                    if (point.isGroup) {
+                        client.setGroupState(username, point.hueLightId, on)
+                        delay(1500)
+                        client.setGroupState(username, point.hueLightId, off)
+                        delay(1500)
+                    } else {
+                        client.setLightState(username, point.hueLightId, on)
+                        delay(1500)
+                        client.setLightState(username, point.hueLightId, off)
+                        delay(1500)
+                    }
+                } catch (e: CancellationException) {
+                    throw e
+                } catch (_: Exception) {
+                    delay(2000)
+                }
+            }
+        } finally {
+            withContext(NonCancellable) {
+                try {
+                    val restore = LightStateRequest(on = wasOn)
+                    if (point.isGroup) client.setGroupState(username, point.hueLightId, restore)
+                    else client.setLightState(username, point.hueLightId, restore)
+                } catch (_: Exception) {}
+            }
+        }
+    }
+
+    private fun nearestLight(current: Map<String, Double>, lights: List<HueLightPoint>): HueLightPoint? {
+        if (current.isEmpty()) return null
+        return lights.mapNotNull { light ->
+            val common = current.keys.intersect(light.readings.keys)
+            if (common.isEmpty()) return@mapNotNull null
+            val dist = sqrt(common.sumOf { k -> (current[k]!! - light.readings[k]!!).let { d -> d * d } })
+            light to dist
+        }.filter { (_, d) -> d < HAUNT_THRESHOLD }
+            .minByOrNull { (_, d) -> d }?.first
+    }
+
+    private fun createNotificationChannel() {
+        val ch = NotificationChannel(CHANNEL_ID, "Haunt", NotificationManager.IMPORTANCE_LOW)
+            .apply { description = "Haunt mode is active" }
+        (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(ch)
+    }
+
+    private fun buildNotification(lightName: String?): Notification {
+        val tap = PendingIntent.getActivity(
+            this, 0,
+            Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
+            PendingIntent.FLAG_IMMUTABLE,
+        )
+        return NotificationCompat.Builder(this, CHANNEL_ID)
+            .setSmallIcon(R.mipmap.ic_launcher)
+            .setContentTitle("Haunt active")
+            .setContentText(if (lightName != null) "Near: $lightName" else "Listening…")
+            .setContentIntent(tap)
+            .setOngoing(true)
+            .setPriority(NotificationCompat.PRIORITY_LOW)
+            .build()
+    }
+
+    private fun updateNotification(lightName: String?) {
+        (getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
+            .notify(NOTIF_ID, buildNotification(lightName))
+    }
+
+    companion object {
+        private const val NOTIF_ID = 1001
+        private const val CHANNEL_ID = "haunt_service"
+        private const val HAUNT_THRESHOLD = 2.0
+
+        private val _hauntTarget = MutableStateFlow<String?>(null)
+        val hauntTarget: StateFlow<String?> = _hauntTarget
+
+        fun start(context: Context) =
+            context.startForegroundService(Intent(context, HauntService::class.java))
+
+        fun stop(context: Context) =
+            context.stopService(Intent(context, HauntService::class.java))
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
index 5169e51..7826a03 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
@@ -5,22 +5,16 @@ import android.content.Context
 import android.graphics.Color
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import net.fuzzyworld.haunt.hue.HueBridgeClient
-import net.fuzzyworld.haunt.hue.LightStateRequest
-import kotlin.math.sqrt
+import net.fuzzyworld.haunt.HauntService
 import net.fuzzyworld.haunt.hue.HueBridgeConfig
 import net.fuzzyworld.haunt.hue.HueLightPoint
 import net.fuzzyworld.haunt.hue.HueLightStore
@@ -56,10 +50,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     private val _hauntMode = MutableStateFlow(false)
     val hauntMode: StateFlow<Boolean> = _hauntMode
 
-    private val _hauntTarget = MutableStateFlow<String?>(null) // hueLightId currently being haunted
-    val hauntTarget: StateFlow<String?> = _hauntTarget
-
-    private var hauntJob: Job? = null
+    val hauntTarget: StateFlow<String?> = HauntService.hauntTarget
 
     private val _networkDevices = MutableStateFlow<List<NetworkDevice>>(emptyList())
     val networkDevices: StateFlow<List<NetworkDevice>> = _networkDevices
@@ -165,92 +156,12 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     fun toggleHauntMode() {
         val active = !_hauntMode.value
         _hauntMode.value = active
-        if (active) startHaunting() else stopHaunting()
-    }
-
-    private fun startHaunting() {
-        hauntJob?.cancel()
-        hauntJob = viewModelScope.launch {
-            var blinkJob: Job? = null
-            var currentId: String? = null
-            _readings.collect { readings ->
-                val target = nearestLight(readings.associate { it.bssid to it.distanceMeters })
-                if (target?.hueLightId != currentId) {
-                    blinkJob?.cancel()
-                    currentId = target?.hueLightId
-                    _hauntTarget.value = currentId
-                    if (target != null) {
-                        blinkJob = launch(Dispatchers.IO) { blinkLoop(target) }
-                    }
-                }
-            }
-        }
-    }
-
-    private fun stopHaunting() {
-        hauntJob?.cancel()
-        hauntJob = null
-        _hauntTarget.value = null
-    }
-
-    private suspend fun blinkLoop(point: HueLightPoint) {
-        val ip = hueConfig.bridgeIp ?: return
-        val username = hueConfig.username ?: return
-        val client = HueBridgeClient(ip)
-
-        val wasOn = try {
-            if (point.isGroup) client.getGroupState(username, point.hueLightId)
-            else client.getLightState(username, point.hueLightId)
-        } catch (_: Exception) { false }
-
-        try {
-            while (true) {
-                try {
-                    val on = LightStateRequest(on = true)
-                    val off = LightStateRequest(on = false)
-                    if (point.isGroup) {
-                        client.setGroupState(username, point.hueLightId, on)
-                        delay(1500)
-                        client.setGroupState(username, point.hueLightId, off)
-                        delay(1500)
-                    } else {
-                        client.setLightState(username, point.hueLightId, on)
-                        delay(1500)
-                        client.setLightState(username, point.hueLightId, off)
-                        delay(1500)
-                    }
-                } catch (e: CancellationException) {
-                    throw e
-                } catch (_: Exception) {
-                    delay(2000)
-                }
-            }
-        } finally {
-            withContext(NonCancellable) {
-                try {
-                    val restore = LightStateRequest(on = wasOn)
-                    if (point.isGroup) client.setGroupState(username, point.hueLightId, restore)
-                    else client.setLightState(username, point.hueLightId, restore)
-                } catch (_: Exception) {}
-            }
-        }
-    }
-
-    private fun nearestLight(current: Map<String, Double>): HueLightPoint? {
-        if (current.isEmpty()) return null
-        return _lightPoints.value.mapNotNull { light ->
-            val common = current.keys.intersect(light.readings.keys)
-            if (common.isEmpty()) return@mapNotNull null
-            val dist = sqrt(common.sumOf { k -> (current[k]!! - light.readings[k]!!).let { d -> d * d } })
-            light to dist
-        }.filter { (_, d) -> d < HAUNT_THRESHOLD }
-            .minByOrNull { (_, d) -> d }
-            ?.first
+        if (active) HauntService.start(getApplication())
+        else HauntService.stop(getApplication())
     }
 
     override fun onCleared() {
         super.onCleared()
-        hauntJob?.cancel()
         viewModelScope.launch(Dispatchers.IO) { historyStore.save(_history.value) }
     }
 
@@ -335,7 +246,4 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         return Color.HSVToColor(floatArrayOf(nextHue, 0.72f, 0.82f))
     }
 
-    companion object {
-        private const val HAUNT_THRESHOLD = 2.0
-    }
 }