haunt / commit
Run haunt mode as a foreground service for reliable background operation
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 - } }