From c5d83076976972f986cb208357cdffe8e845dc13 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 08:49:56 +0100 Subject: [PATCH] Retain proximity registrations after partial SDK failures --- .../com/utun/chat/data/ProximityManager.kt | 72 ++++++++++++++++--- .../kotlin/android/hardware/HostSensors.kt | 5 +- .../data/ProximityManagerLifecycleTest.kt | 50 +++++++++++++ 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt index b6de8648..cb14c2e3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt @@ -17,12 +17,31 @@ import kotlinx.coroutines.flow.StateFlow * near: true — расстояние меньше maximumRange, false — FAR; null — показания ещё нет или монитор остановлен. * Без датчика start публикует false (экран не блокируется). Ошибка доступа/регистрации оставляет null. * Runtime-разрешения не нужны; отказы ОС логируются, после отказа регистрации start можно повторить. + * Failed unregister сохраняет listener/manager и повторяется на main через 250 мс..30 с; новый start ждёт его ACK. */ -class ProximityManager(context: Context) { +class ProximityManager(private val context: Context) { + companion object { private val retained = java.util.concurrent.ConcurrentHashMap.newKeySet() } private val main = Handler(Looper.getMainLooper()) - private val sensorManager = context.getSystemService(SensorManager::class.java) - private val sensor: Sensor? = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY) + private var sensorManager: SensorManager? = null + private var sensor: Sensor? = null + private var lookupFailed = false + private val retired = mutableMapOf() + private var scheduled = false + private var retryDelay = 250L + private val retry = Runnable { scheduled = false; cleanup() } + init { lookupSensor() } + + private fun lookupSensor() { + try { + sensorManager = context.getSystemService(SensorManager::class.java) + sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY) + lookupFailed = sensorManager == null + } catch (e: Exception) { + lookupFailed = true; sensor = null + LogManager.addLog("ERROR", "Proximity", "Sensor lookup failed; proximity remains unknown: $e") + } + } // null — первое показание ещё не получено; это не подтверждённое FAR. private val _near = MutableStateFlow(null) @@ -39,6 +58,13 @@ class ProximityManager(context: Context) { fun start() { checkMain() if (started) return + cleanup() + if (retired.isNotEmpty()) { + LogManager.addLog("ERROR", "Proximity", "Sensor start blocked by previous unregister; owner retained") + return + } + if (lookupFailed) lookupSensor() + if (lookupFailed && sensorManager != null) return val manager = sensorManager if (manager == null) { LogManager.addLog("ERROR", "Proximity", "sensor service unavailable; proximity remains unknown") @@ -52,9 +78,18 @@ class ProximityManager(context: Context) { return } val l = object : SensorEventListener { + private var badReading = false override fun onSensorChanged(event: SensorEvent) { if (!started || listener !== this) return // Запоздалое событие предыдущей регистрации не относится к текущему ожиданию. - val nearNow = event.values[0] < event.sensor.maximumRange + val value = event.values.firstOrNull() + val range = event.sensor.maximumRange + if (value == null || !value.isFinite() || value < 0 || !range.isFinite() || range <= 0) { + _near.value = null + if (!badReading) LogManager.addLog("WARN", "Proximity", "Invalid reading value=$value maximumRange=$range; remains unknown") + badReading = true; return + } + badReading = false + val nearNow = value < range if (_near.value != nearNow) { _near.value = nearNow LogManager.addLog("INFO", "Proximity", @@ -68,12 +103,12 @@ class ProximityManager(context: Context) { val registered = try { manager.registerListener(l, s, SensorManager.SENSOR_DELAY_NORMAL, main) } catch (e: Exception) { - listener = null; started = false + stop() LogManager.addLog("ERROR", "Proximity", "sensor registration failed; proximity remains unknown: ${e.javaClass.simpleName}: ${e.message}") return } if (!registered) { - listener = null; started = false + stop() LogManager.addLog("WARN", "Proximity", "sensor registration failed; proximity remains unknown") return } @@ -88,13 +123,28 @@ class ProximityManager(context: Context) { listener = null started = false _near.value = null - if (!wasStarted) return - try { - current?.let { sensorManager?.unregisterListener(it) } + if (current != null) retired[current] = checkNotNull(sensorManager) + cleanup() + if (wasStarted) LogManager.addLog("INFO", "Proximity", "stopped cleanupPending=${retired.isNotEmpty()}") + } + + private fun cleanup() { + for ((old, manager) in retired.toMap()) try { + manager.unregisterListener(old); retired.remove(old) + LogManager.addLog("INFO", "Proximity", "Sensor listener removal confirmed owner=${System.identityHashCode(this)}") } catch (e: Exception) { - LogManager.addLog("WARN", "Proximity", "sensor listener removal failed: ${e.javaClass.simpleName}: ${e.message}") + LogManager.addLog("WARN", "Proximity", "sensor listener removal failed; owner retained: $e") + } + if (retired.isEmpty()) { + main.removeCallbacks(retry); scheduled = false; retained.remove(this); retryDelay = 250L + } else { + retained.add(this) + if (!scheduled) { + scheduled = main.postDelayed(retry, retryDelay) + if (!scheduled) LogManager.addLog("ERROR", "Proximity", "Sensor cleanup retry rejected; owner retained") + else retryDelay = (retryDelay * 2).coerceAtMost(30_000) + } } - LogManager.addLog("INFO", "Proximity", "stopped") } private fun checkMain() { diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/hardware/HostSensors.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/hardware/HostSensors.kt index abcc7b8a..991f0168 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/hardware/HostSensors.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/hardware/HostSensors.kt @@ -17,13 +17,15 @@ class SensorManager { var sensor: Sensor? = Sensor() var acceptRegistration = true var failRegistration = false + var failRegistrationAfter = false var failRemoval = false + var failLookup = false var registerAttempts = 0 var removeAttempts = 0 val registrations = mutableListOf() val active = mutableSetOf() - fun getDefaultSensor(type: Int): Sensor? { check(type == Sensor.TYPE_PROXIMITY); return sensor } + fun getDefaultSensor(type: Int): Sensor? { check(type == Sensor.TYPE_PROXIMITY && !failLookup); return sensor } fun registerListener(listener: SensorEventListener, sensor: Sensor, delay: Int, handler: Handler = Handler(Looper.getMainLooper())): Boolean { check(delay == SENSOR_DELAY_NORMAL) @@ -32,6 +34,7 @@ class SensorManager { check(!failRegistration) { "sensor registration unavailable" } if (!acceptRegistration) return false active.add(listener) + check(!failRegistrationAfter) { "sensor registration failed after effect" } return true } fun unregisterListener(listener: SensorEventListener) { diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/ProximityManagerLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/ProximityManagerLifecycleTest.kt index 59b395b9..7187358c 100644 --- a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/ProximityManagerLifecycleTest.kt +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/ProximityManagerLifecycleTest.kt @@ -3,6 +3,7 @@ package com.utun.chat.data import android.content.Context import android.hardware.SensorManager import android.os.Looper +import android.os.Handler import java.util.concurrent.atomic.AtomicReference import org.junit.After import org.junit.Assert.* @@ -16,6 +17,7 @@ class ProximityManagerLifecycleTest { private lateinit var proximity: ProximityManager @Before fun prepare() { + Handler.rejectDelayed = false assertSame(Looper.getMainLooper(), Looper.myLooper()) context = Context() manager = requireNotNull(context.sensorManager) @@ -25,6 +27,7 @@ class ProximityManagerLifecycleTest { } @After fun release() { + Handler.rejectDelayed = false manager.failRemoval = false proximity.stop() Looper.getMainLooper().dispatchAll() @@ -175,6 +178,53 @@ class ProximityManagerLifecycleTest { assertNull(proximity.near.value) } + @Test fun failedRemovalIsRetriedWithoutAnotherStartAndKeepsItsOriginalManager() { + proximity.start(); manager.failRemoval = true; proximity.stop() + assertEquals(1, manager.active.size) + context.sensorManager = SensorManager(); manager.failRemoval = false + Looper.getMainLooper().advanceBy(250) + assertTrue(manager.active.isEmpty()); assertEquals(2, manager.removeAttempts) + } + + @Test fun unresolvedOldRegistrationBlocksNewStart() { + proximity.start(); manager.failRemoval = true; proximity.stop(); proximity.start() + assertEquals(1, manager.registerAttempts); assertNull(proximity.near.value) + manager.failRemoval = false; proximity.start() + assertEquals(2, manager.registerAttempts); assertEquals(1, manager.active.size) + } + + @Test fun partialRegistrationFailureIsRetainedUntilUnregisterAck() { + manager.failRegistrationAfter = true; manager.failRemoval = true; proximity.start() + assertEquals(1, manager.active.size); assertNull(proximity.near.value) + manager.failRemoval = false; manager.failRegistrationAfter = false + Looper.getMainLooper().advanceBy(250) + assertTrue(manager.active.isEmpty()); proximity.start(); assertEquals(1, manager.active.size) + } + + @Test fun rejectedRetryTimerStillAllowsExplicitStopToReleaseRegistration() { + proximity.start(); manager.failRemoval = true; Handler.rejectDelayed = true; proximity.stop() + assertTrue(LogManager.entries.any { it.level == "ERROR" && "retry rejected" in it.message }) + manager.failRemoval = false; Handler.rejectDelayed = false; proximity.stop() + assertTrue(manager.active.isEmpty()) + } + + @Test fun sensorLookupExceptionKeepsUnknownAndCanRecoverAtStart() { + manager.failLookup = true; proximity = ProximityManager(context) + assertFalse(proximity.available); proximity.start(); assertNull(proximity.near.value) + manager.failLookup = false; proximity.start(); assertTrue(proximity.available) + manager.deliver(0, 5f); Looper.getMainLooper().dispatchAll(); assertEquals(false, proximity.near.value) + } + + @Test fun malformedReadingsRemainUnknownAndAreLoggedOnceUntilValidData() { + proximity.start() + val registration = manager.registrations.single() + for (values in listOf(floatArrayOf(), floatArrayOf(Float.NaN), floatArrayOf(-1f))) + registration.listener.onSensorChanged(android.hardware.SensorEvent(registration.sensor, values)) + assertNull(proximity.near.value) + assertEquals(1, LogManager.entries.count { it.level == "WARN" && "Invalid reading" in it.message }) + manager.deliver(0, 0f); Looper.getMainLooper().dispatchAll(); assertEquals(true, proximity.near.value) + } + private fun onWorker(action: () -> Unit): Throwable? { val result = AtomicReference() val worker = Thread({ try { action() } catch (error: Throwable) { result.set(error) } }, "proximity-test-worker")