diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkActivityMonitor.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkActivityMonitor.kt index cada37b7..2e55aab6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkActivityMonitor.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkActivityMonitor.kt @@ -1,8 +1,8 @@ package com.utun.chat.data -import android.content.Context import android.net.ConnectivityManager -import android.os.Build +import android.os.Handler +import android.os.Looper import com.utun.chat.ChatApplication /** @@ -11,38 +11,54 @@ import com.utun.chat.ChatApplication * Пробуждает standby-ядро досрочно: когда в фазе SLEEP прошёл min_sleep, * приход сетевой активности запускает следующий активный цикл раньше. * - * В колбэке не делаем тяжёлую работу — только дёшевый JNI-вызов - * NativeLib.notifyNetworkActive(), который постит событие на uasync-поток. + * start/stop вызывает HeadlessService на main thread. API доступен с 21, весь minSdk 26 поддерживается. + * Callback проходит main-очередь, проверяет свою регистрацию и вызывает JNI, который постит событие в uasync. + * После stop или нового start старые callbacks игнорируются; повторные start/stop безопасны. */ object NetworkActivityMonitor { - private var started = false + private val handler = Handler(Looper.getMainLooper()) + private var registration: Registration? = null - private val listener = ConnectivityManager.OnNetworkActiveListener { - NativeLib.notifyNetworkActive() - } - - fun start() { - if (started) return - /* addDefaultNetworkActiveListener доступен с API 28 (P) */ - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return - val ctx = ChatApplication.instance.applicationContext - val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return - cm.addDefaultNetworkActiveListener(listener) - started = true - LogManager.addLog("INFO", "NET", "network activity listener started") - /* Если сеть уже активна в момент запуска — сразу уведомляем */ - if (cm.isDefaultNetworkActive) { - NativeLib.notifyNetworkActive() + private class Registration(val manager: ConnectivityManager) { + val listener = ConnectivityManager.OnNetworkActiveListener { + handler.post { + if (registration === this) NativeLib.notifyNetworkActive() + } } } + /** Зарегистрировать listener; отказ/отсутствие manager логируется и допускает следующую попытку start. */ + fun start() { + if (registration != null) return + val cm = ChatApplication.instance.getSystemService(ConnectivityManager::class.java) + if (cm == null) { LogManager.addLog("ERROR", "NET", "network activity manager unavailable"); return } + val next = Registration(cm) + try { + cm.addDefaultNetworkActiveListener(next.listener) + } catch (e: Exception) { + LogManager.addLog("ERROR", "NET", "network activity listener registration failed: ${e.javaClass.simpleName}: ${e.message}") + return + } + registration = next + LogManager.addLog("INFO", "NET", "network activity listener started") + val active = try { + cm.isDefaultNetworkActive + } catch (e: Exception) { + LogManager.addLog("WARN", "NET", "network activity query failed: ${e.javaClass.simpleName}: ${e.message}") + false + } + if (active) NativeLib.notifyNetworkActive() + } + + /** Сначала запретить callbacks, затем снять listener у владельца регистрации; ошибку снятия логировать. */ fun stop() { - if (!started) return - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return - val ctx = ChatApplication.instance.applicationContext - val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return - cm.removeDefaultNetworkActiveListener(listener) - started = false + val current = registration ?: return + registration = null + try { + current.manager.removeDefaultNetworkActiveListener(current.listener) + } catch (e: Exception) { + LogManager.addLog("WARN", "NET", "network activity listener removal failed: ${e.javaClass.simpleName}: ${e.message}") + } LogManager.addLog("INFO", "NET", "network activity listener stopped") } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkChangeMonitor.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkChangeMonitor.kt index 5834d93a..f3bffd60 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkChangeMonitor.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NetworkChangeMonitor.kt @@ -7,98 +7,134 @@ import android.os.Handler import android.os.Looper import com.utun.chat.ChatApplication -/** ConnectivityManager передаёт актуальный конфиг сокетов в uasync без рестарта ядра. - * Регистрация живёт в HeadlessService; состояние очереди обновлений — на main thread. */ +/** + * Объединяет события ConnectivityManager за 500 мс и передаёт снимок сокетов в JNI без рестарта ядра. + * start/stop/onCoreStarted и состояние очереди — main thread; подготовка конфига — один worker даже через stop/start. + * stop снимает регистрацию и запрещает публикацию; начатый IO может завершиться. SERVICE_STARTED инвалидирует прежний снимок. + * true от JNI означает передачу строки, не подтверждение применения: parse/reload выполняется в uasync и логируется там. + */ object NetworkChangeMonitor { - private var started = false - private var generation = 0 + private var registration: Registration? = null + private var coreEpoch = Any() private var updating = false private var pending = false - private var lastInterfaces: List>? = null + private var lastInterfaces: List>? = null // Последний снимок, переданный в JNI; не ACK применения. private const val DEBOUNCE_MS = 500L private val handler = Handler(Looper.getMainLooper()) private val updateRunnable = Runnable { updateSockets() } - private val callback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) = schedule() - override fun onLost(network: Network) = schedule() - override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = schedule() + private class Registration(val manager: ConnectivityManager) { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = schedule(this@Registration) + override fun onLost(network: Network) = schedule(this@Registration) + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = schedule(this@Registration) + } } - private fun schedule() { + private fun schedule(owner: Registration) { handler.post { - if (!started) return@post + if (registration !== owner) return@post handler.removeCallbacks(updateRunnable) handler.postDelayed(updateRunnable, DEBOUNCE_MS) } } private fun updateSockets() { - if (!started || !NativeLib.isReady()) return - if (updating) { pending = true; return } + val owner = registration ?: return + if (!NativeLib.isReady()) { + LogManager.addLog("DEBUG", "NET", "socket snapshot deferred: core not ready") + return + } + if (updating) { + if (!pending) LogManager.addLog("DEBUG", "NET", "socket snapshot pending: previous config worker running") + pending = true + return + } updating = true - val token = generation - Thread { - var interfaces: List>? = null - var config: String? = null - try { - val provider = ChatApplication.instance.configProvider - interfaces = provider.getAvailableInterfaces() - config = provider.buildConfigText() - } catch (e: Exception) { - LogManager.addLog("ERROR", "NET", "network config update failed: ${e.message}") - } finally { - val current = interfaces - val text = config - handler.post { - if (!started || token != generation) return@post - updating = false - if (text != null && current != null && NativeLib.isReady()) { - val previous = lastInterfaces?.joinToString(", ") { (n, ip) -> "$n=$ip" } ?: "n/a" - val next = current.joinToString(", ") { (n, ip) -> "$n=$ip" } - lastInterfaces = current - LogManager.addLog("INFO", "NET", "network change: [$previous] -> [$next]") - NativeLib.notifyNetworkActive() - if (!NativeLib.updateSockets(text)) LogManager.addLog("ERROR", "NET", "socket update rejected") + pending = false + val token = coreEpoch + LogManager.addLog("DEBUG", "NET", "network config preparation started") + try { + Thread({ + var interfaces: List>? = null + var config: String? = null + try { + val provider = ChatApplication.instance.configProvider + interfaces = provider.getAvailableInterfaces() + config = provider.buildConfigText() + } catch (e: Exception) { + LogManager.addLog("ERROR", "NET", "network config update failed: ${e.javaClass.simpleName}: ${e.message}") + } finally { + val current = interfaces + val text = config + handler.post { + // stop не сбрасывает updating: следующий жизненный цикл ждёт этот единственный worker. + updating = false + val replaced = registration !== owner + val restarted = coreEpoch !== token + val stale = replaced || restarted + if (stale) { + LogManager.addLog("DEBUG", "NET", "retired network config discarded: registration=$replaced core=$restarted") + } else if (text != null && current != null && NativeLib.isReady()) { + val previous = lastInterfaces?.joinToString(", ") { (n, ip) -> "$n=$ip" } ?: "n/a" + val next = current.joinToString(", ") { (n, ip) -> "$n=$ip" } + NativeLib.notifyNetworkActive() + if (NativeLib.updateSockets(text)) { + lastInterfaces = current + LogManager.addLog("INFO", "NET", "socket config handed to JNI: [$previous] -> [$next] chars=${text.length}") + } else LogManager.addLog("ERROR", "NET", "socket update rejected by JNI") + } else if (text != null && current != null) { + LogManager.addLog("DEBUG", "NET", "prepared network config discarded: core not ready") + } + val active = registration + if (active != null && (pending || stale)) { + pending = false + schedule(active) + } } - if (pending) { pending = false; schedule() } } - } - }.start() + }, "utun-network-config").start() + } catch (e: Exception) { + updating = false + LogManager.addLog("ERROR", "NET", "network config worker launch failed: ${e.javaClass.simpleName}: ${e.message}") + } } - // Повторный снимок после SERVICE_STARTED покрывает изменения сети во время инициализации. - fun onCoreStarted() = schedule() + /** Повторный снимок после SERVICE_STARTED; старый worker завершится, но его конфиг в новое ядро не передаётся. */ + fun onCoreStarted() { + coreEpoch = Any() + LogManager.addLog("DEBUG", "NET", "socket snapshot invalidated: core started") + registration?.let { schedule(it) } + } + /** Жизненный цикл HeadlessService: зарегистрировать ровно один callback, false — ошибка с возможностью повтора. */ fun start(): Boolean { - if (started) return true + if (registration != null) return true val cm = ChatApplication.instance.getSystemService(ConnectivityManager::class.java) if (cm == null) { LogManager.addLog("ERROR", "NET", "ConnectivityManager unavailable"); return false } + val next = Registration(cm) try { - cm.registerDefaultNetworkCallback(callback) + cm.registerDefaultNetworkCallback(next.callback) } catch (e: Exception) { - LogManager.addLog("ERROR", "NET", "network listener registration failed: ${e.message}") + LogManager.addLog("ERROR", "NET", "network listener registration failed: ${e.javaClass.simpleName}: ${e.message}") return false } - generation++ - started = true + registration = next LogManager.addLog("INFO", "NET", "network change listener started") return true } + /** Запретить публикацию/поздние callbacks; уже запущенная подготовка IO не прерывается и не дублируется новым start. */ fun stop() { - if (!started) return - started = false - generation++ + val current = registration ?: return + registration = null pending = false - updating = false lastInterfaces = null handler.removeCallbacks(updateRunnable) - val cm = ChatApplication.instance.getSystemService(ConnectivityManager::class.java) try { - cm?.unregisterNetworkCallback(callback) + current.manager.unregisterNetworkCallback(current.callback) } catch (e: Exception) { - LogManager.addLog("WARN", "NET", "network listener removal failed: ${e.message}") + LogManager.addLog("WARN", "NET", "network listener removal failed: ${e.javaClass.simpleName}: ${e.message}") } LogManager.addLog("INFO", "NET", "network change listener stopped") } diff --git a/tools/chatgui-android/router-tests/build.gradle.kts b/tools/chatgui-android/router-tests/build.gradle.kts index 8e49e152..a05ce83a 100644 --- a/tools/chatgui-android/router-tests/build.gradle.kts +++ b/tools/chatgui-android/router-tests/build.gradle.kts @@ -5,10 +5,10 @@ plugins { kotlin { jvmToolchain(17) sourceSets.named("main") { - // Компилируем сам router приложения; Android fixtures существуют только в этом JVM-модуле. + // Компилируем настоящие компоненты приложения; Android fixtures существуют только в этом JVM-модуле. kotlin.srcDir(providers.gradleProperty("routerSourceDir").getOrElse("../app/src/main/java")) kotlin.include("android/**", "com/utun/chat/RouterHostApp.kt", "com/utun/chat/data/RouterHostEnvironment.kt", - "com/utun/chat/data/CallAudioRouter.kt") + "com/utun/chat/data/CallAudioRouter.kt", "com/utun/chat/data/NetworkActivityMonitor.kt", "com/utun/chat/data/NetworkChangeMonitor.kt") } } diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/content/Context.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/content/Context.kt index 2587c39d..c072f9a1 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/content/Context.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/content/Context.kt @@ -1,14 +1,23 @@ package android.content import android.media.AudioManager +import android.net.ConnectivityManager +import com.utun.chat.data.HostConfigProvider -/** Только host-тест: один управляемый AudioManager, без устройств/разрешений ОС. */ +/** Только host-тест: управляемые AudioManager/ConnectivityManager, без устройств/разрешений ОС. */ class Context { companion object { const val AUDIO_SERVICE = "audio" } var permissionGranted = true + var connectivityManager: ConnectivityManager? = ConnectivityManager() + val configProvider = HostConfigProvider() + val applicationContext: Context get() = this fun getSystemService(name: String): Any { check(name == AUDIO_SERVICE) return AudioManager.shared } + fun getSystemService(type: Class): T? { + check(type == ConnectivityManager::class.java) + return type.cast(connectivityManager) + } fun checkSelfPermission(name: String): Int = if (permissionGranted) 0 else -1 } diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/net/HostNetwork.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/net/HostNetwork.kt new file mode 100644 index 00000000..cf1a85e6 --- /dev/null +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/net/HostNetwork.kt @@ -0,0 +1,57 @@ +package android.net + +class Network +class LinkProperties + +/** Только host-тест: listener принадлежит конкретному manager; старые callbacks тест может доставить после удаления. */ +class ConnectivityManager { + open class NetworkCallback { + open fun onAvailable(network: Network) {} + open fun onLost(network: Network) {} + open fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {} + } + fun interface OnNetworkActiveListener { fun onNetworkActive() } + val listeners = mutableListOf() + val history = mutableListOf() + val callbacks = mutableListOf() + val callbackHistory = mutableListOf() + var addAttempts = 0 + var removeAttempts = 0 + var failAdd = false + var failRemove = false + var failQuery = false + var active = false + + fun addDefaultNetworkActiveListener(listener: OnNetworkActiveListener) { + addAttempts++ + if (failAdd) throw SecurityException("network listener registration denied") + check(listener !in listeners) + listeners.add(listener) + history.add(listener) + } + + fun removeDefaultNetworkActiveListener(listener: OnNetworkActiveListener) { + removeAttempts++ + if (failRemove) throw IllegalStateException("network listener removal failed") + check(listeners.remove(listener)) { "listener belongs to another manager or was already removed" } + } + + val isDefaultNetworkActive: Boolean get() { + if (failQuery) throw IllegalStateException("network activity query failed") + return active + } + + fun deliver(index: Int) { history[index].onNetworkActive() } + + fun registerDefaultNetworkCallback(callback: NetworkCallback) { + if (failAdd) throw SecurityException("network callback registration denied") + check(callback !in callbacks) + callbacks.add(callback) + callbackHistory.add(callback) + } + + fun unregisterNetworkCallback(callback: NetworkCallback) { + if (failRemove) throw IllegalStateException("network callback removal failed") + check(callbacks.remove(callback)) { "callback belongs to another manager or was already removed" } + } +} diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostOs.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostOs.kt index 02ee3b38..627f0fb6 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostOs.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostOs.kt @@ -1,7 +1,35 @@ package android.os -/** Host-модель API36; callbacks доставляет тест явно, все времена монотонные. */ -object Build { object VERSION { const val SDK_INT = 36 }; object VERSION_CODES { const val S = 31 } } -class Looper { companion object { fun getMainLooper() = Looper() } } -class Handler(val looper: Looper) +import java.util.concurrent.ConcurrentLinkedQueue + +/** Host-модель SDK/главной очереди: callbacks и их доставку тест контролирует явно, без настоящего Android Looper. */ +object Build { object VERSION { var SDK_INT = 36 }; object VERSION_CODES { const val P = 28; const val S = 31 } } +class Looper private constructor() { + private data class Task(val handler: Handler, val runnable: Runnable, val deadline: Long) + private val tasks = ConcurrentLinkedQueue() + @Volatile private var time = 0L + internal fun post(handler: Handler, task: Runnable, delay: Long): Boolean { + tasks.add(Task(handler, task, time + delay)); return true + } + internal fun remove(handler: Handler, task: Runnable) { tasks.removeIf { it.handler === handler && it.runnable === task } } + fun advanceBy(milliseconds: Long) { check(milliseconds >= 0); time += milliseconds; dispatchAll() } + fun dispatchAll() { + check(myLooper() === this) + while (true) { + val next = tasks.filter { it.deadline <= time }.minByOrNull { it.deadline } ?: return + if (tasks.remove(next)) next.runnable.run() + } + } + companion object { + private val main = Looper() + private val owner = Thread.currentThread() + fun getMainLooper() = main + fun myLooper(): Looper? = if (Thread.currentThread() === owner) main else null + } +} +class Handler(val looper: Looper) { + fun post(task: Runnable): Boolean = looper.post(this, task, 0) + fun postDelayed(task: Runnable, delay: Long): Boolean = looper.post(this, task, delay) + fun removeCallbacks(task: Runnable) = looper.remove(this, task) +} object SystemClock { fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000 } diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt b/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt index 78ef0469..2d0c8973 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt @@ -1,6 +1,7 @@ package com.utun.chat.data import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger /** Минимальная поверхность Telecom; Bluetooth lifecycle не создаёт настоящую Connection. */ class UtunConnection { @@ -21,3 +22,34 @@ object LogManager { if (console || level == "ERROR") System.err.println("[$level] [$category] $message") } } + +/** Только host-тест: считает уведомления standby, не загружает JNI и не запускает ядро. */ +object NativeLib { + val networkNotifications = AtomicInteger() + var ready = true + var acceptSocketUpdate = true + val socketUpdates = mutableListOf() + fun notifyNetworkActive() { networkNotifications.incrementAndGet() } + fun isReady(): Boolean = ready + fun updateSockets(config: String): Boolean { socketUpdates.add(config); return acceptSocketUpdate } +} + +/** Управляемая подготовка конфига: тест удерживает реальный worker, считает перекрытия и затем дожидается его завершения. */ +class HostConfigProvider { + val builds = AtomicInteger() + val inBuild = AtomicInteger() + val maximumConcurrentBuilds = AtomicInteger() + private val workers: MutableSet = Collections.synchronizedSet(mutableSetOf()) + @Volatile var interfaces = listOf("wlan0" to "192.0.2.1") + @Volatile var onBuild: (Int) -> String = { "config-$it" } + + fun getAvailableInterfaces(): List> { workers.add(Thread.currentThread()); return interfaces } + fun buildConfigText(): String { + maximumConcurrentBuilds.accumulateAndGet(inBuild.incrementAndGet(), ::maxOf) + try { return onBuild(builds.incrementAndGet()) } finally { inBuild.decrementAndGet() } + } + fun joinWorkers() { + val snapshot = synchronized(workers) { workers.toList() } + snapshot.forEach { it.join(2_000); check(!it.isAlive) { "config worker did not finish" } } + } +} diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/NetworkActivityMonitorLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/NetworkActivityMonitorLifecycleTest.kt new file mode 100644 index 00000000..6d003b9a --- /dev/null +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/NetworkActivityMonitorLifecycleTest.kt @@ -0,0 +1,148 @@ +package com.utun.chat.data + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Build +import android.os.Looper +import com.utun.chat.ChatApplication +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящий monitor: владение listener, поздние callbacks и исключения Android API; Binder/ОС не моделируются. */ +class NetworkActivityMonitorLifecycleTest { + private lateinit var manager: ConnectivityManager + private lateinit var context: Context + + @Before fun prepare() { + Build.VERSION.SDK_INT = 36 + context = Context() + manager = requireNotNull(context.connectivityManager) + ChatApplication.instance = context + NativeLib.networkNotifications.set(0) + LogManager.entries.clear() + LogManager.console = true + } + + @After fun release() { + // Старая реализация могла бросить исключение посередине stop(); fixture позволяет завершить отрицательный контроль. + Build.VERSION.SDK_INT = 36 + ChatApplication.instance = context + context.connectivityManager = manager + manager.failAdd = false + manager.failRemove = false + manager.failQuery = false + NetworkActivityMonitor.stop() + Looper.getMainLooper().dispatchAll() + assertTrue("listener leaked", manager.listeners.isEmpty()) + } + + @Test fun minimumSupportedAndroidStillRegistersListener() { + Build.VERSION.SDK_INT = 26 + NetworkActivityMonitor.start() + assertEquals(1, manager.listeners.size) + manager.deliver(0) + Looper.getMainLooper().dispatchAll() + assertEquals(1, NativeLib.networkNotifications.get()) + } + + @Test fun startsAndStopsAreIdempotent() { + repeat(3) { NetworkActivityMonitor.start() } + assertEquals(1, manager.addAttempts) + repeat(3) { NetworkActivityMonitor.stop() } + assertEquals(1, manager.removeAttempts) + assertTrue(manager.listeners.isEmpty()) + } + + @Test fun initiallyActiveNetworkWakesCoreOnce() { + manager.active = true + NetworkActivityMonitor.start() + NetworkActivityMonitor.start() + assertEquals(1, NativeLib.networkNotifications.get()) + } + + @Test fun callbackFromBinderIsDeliveredThroughMainQueue() { + NetworkActivityMonitor.start() + val binder = Thread { manager.deliver(0) } + binder.start() + binder.join(2_000) + assertFalse("fixture callback blocked", binder.isAlive) + assertEquals(0, NativeLib.networkNotifications.get()) + Looper.getMainLooper().dispatchAll() + assertEquals(1, NativeLib.networkNotifications.get()) + } + + @Test fun queuedCallbackIsIgnoredAfterStop() { + NetworkActivityMonitor.start() + manager.deliver(0) + NetworkActivityMonitor.stop() + Looper.getMainLooper().dispatchAll() + assertEquals(0, NativeLib.networkNotifications.get()) + } + + @Test fun previousRegistrationCannotWakeRestartedCore() { + NetworkActivityMonitor.start() + NetworkActivityMonitor.stop() + NetworkActivityMonitor.start() + manager.deliver(0) + Looper.getMainLooper().dispatchAll() + assertEquals(0, NativeLib.networkNotifications.get()) + manager.deliver(1) + Looper.getMainLooper().dispatchAll() + assertEquals(1, NativeLib.networkNotifications.get()) + } + + @Test fun stopUnregistersFromManagerThatOwnsListener() { + NetworkActivityMonitor.start() + context.connectivityManager = ConnectivityManager() + NetworkActivityMonitor.stop() + assertEquals(1, manager.removeAttempts) + assertTrue(manager.listeners.isEmpty()) + } + + @Test fun missingManagerIsLoggedAndCanBeRetried() { + context.connectivityManager = null + NetworkActivityMonitor.start() + assertTrue(LogManager.entries.any { it.level == "ERROR" && it.category == "NET" }) + context.connectivityManager = manager + NetworkActivityMonitor.start() + assertEquals(1, manager.listeners.size) + } + + @Test fun failedRegistrationIsLoggedAndCanBeRetried() { + manager.failAdd = true + NetworkActivityMonitor.start() + assertTrue(LogManager.entries.any { it.level == "ERROR" && it.message.contains("registration") }) + manager.failAdd = false + NetworkActivityMonitor.start() + assertEquals(2, manager.addAttempts) + assertEquals(1, manager.listeners.size) + } + + @Test fun failedInitialQueryKeepsRegisteredListenerUsable() { + manager.failQuery = true + NetworkActivityMonitor.start() + assertTrue(LogManager.entries.any { it.level == "WARN" && it.message.contains("query") }) + manager.deliver(0) + Looper.getMainLooper().dispatchAll() + assertEquals(1, NativeLib.networkNotifications.get()) + } + + @Test fun removalFailureDoesNotLeaveMonitorStarted() { + NetworkActivityMonitor.start() + manager.failRemove = true + NetworkActivityMonitor.stop() + assertTrue(LogManager.entries.any { it.level == "WARN" && it.message.contains("removal") }) + manager.failRemove = false + manager.listeners.clear() // ОС fixture не выполнила удаление при инъекции исключения. + NetworkActivityMonitor.start() + assertEquals(2, manager.addAttempts) + manager.deliver(0) + Looper.getMainLooper().dispatchAll() + assertEquals(0, NativeLib.networkNotifications.get()) + manager.deliver(1) + Looper.getMainLooper().dispatchAll() + assertEquals(1, NativeLib.networkNotifications.get()) + } +} diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/NetworkChangeMonitorLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/NetworkChangeMonitorLifecycleTest.kt new file mode 100644 index 00000000..78c5ab1c --- /dev/null +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/NetworkChangeMonitorLifecycleTest.kt @@ -0,0 +1,251 @@ +package com.utun.chat.data + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.os.Looper +import com.utun.chat.ChatApplication +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящий monitor: debounce в виртуальной main-очереди, реальные worker и контролируемая подготовка конфига. */ +class NetworkChangeMonitorLifecycleTest { + private lateinit var context: Context + private lateinit var manager: ConnectivityManager + private val loop get() = Looper.getMainLooper() + private val provider get() = context.configProvider + private val blocked = mutableListOf() + + @Before fun prepare() { + context = Context() + ChatApplication.instance = context + manager = requireNotNull(context.connectivityManager) + NativeLib.ready = true + NativeLib.acceptSocketUpdate = true + NativeLib.networkNotifications.set(0) + NativeLib.socketUpdates.clear() + LogManager.entries.clear() + } + + @After fun release() { + context.connectivityManager = manager + manager.failRemove = false + NetworkChangeMonitor.stop() + blocked.forEach { it.countDown() } + provider.joinWorkers() + loop.dispatchAll() + assertTrue("network callback leaked", manager.callbacks.isEmpty()) + NativeLib.ready = true + NativeLib.acceptSocketUpdate = true + } + + private fun awaitUpdates(count: Int) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (NativeLib.socketUpdates.size < count && System.nanoTime() < deadline) { + loop.dispatchAll() + Thread.sleep(1) + } + assertEquals("updates=${NativeLib.socketUpdates}, logs=${LogManager.entries.takeLast(8)}", count, NativeLib.socketUpdates.size) + } + + private fun request(index: Int = manager.callbackHistory.lastIndex) { + manager.callbackHistory[index].onAvailable(Network()) + loop.dispatchAll() + loop.advanceBy(500) + } + + private fun blockFirst(): Pair { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + blocked.add(release) + provider.onBuild = { index -> + if (index == 1) { + entered.countDown() + check(release.await(2, TimeUnit.SECONDS)) { "test did not release first config worker" } + } + "config-$index" + } + return entered to release + } + + @Test fun startAndStopOwnExactlyOneCallback() { + repeat(3) { assertTrue(NetworkChangeMonitor.start()) } + assertEquals(1, manager.callbacks.size) + repeat(3) { NetworkChangeMonitor.stop() } + assertTrue(manager.callbacks.isEmpty()) + } + + @Test fun burstsAreDebouncedBeforePreparingConfig() { + assertTrue(NetworkChangeMonitor.start()) + manager.callbackHistory[0].onAvailable(Network()) + loop.dispatchAll() + loop.advanceBy(250) + manager.callbackHistory[0].onLinkPropertiesChanged(Network(), LinkProperties()) + loop.dispatchAll() + loop.advanceBy(250) + assertEquals(0, provider.builds.get()) + loop.advanceBy(250) + awaitUpdates(1) + assertEquals(listOf("config-1"), NativeLib.socketUpdates) + } + + @Test fun oldPlatformCallbackCannotScheduleWorkAfterRestart() { + val unexpected = CountDownLatch(1) + provider.onBuild = { unexpected.countDown(); "unexpected" } + assertTrue(NetworkChangeMonitor.start()) + NetworkChangeMonitor.stop() + assertTrue(NetworkChangeMonitor.start()) + request(0) + assertFalse("retired callback started a worker", unexpected.await(200, TimeUnit.MILLISECONDS)) + assertEquals(0, provider.builds.get()) + assertTrue(NativeLib.socketUpdates.isEmpty()) + } + + @Test fun stoppedWorkerCannotUpdateCore() { + val (entered, release) = blockFirst() + assertTrue(NetworkChangeMonitor.start()) + request() + assertTrue(entered.await(2, TimeUnit.SECONDS)) + NetworkChangeMonitor.stop() + release.countDown() + provider.joinWorkers() + loop.dispatchAll() + assertTrue(NativeLib.socketUpdates.isEmpty()) + assertEquals(0, NativeLib.networkNotifications.get()) + } + + @Test fun restartWaitsForPreviousWorkerBeforeStartingNext() { + val (entered, release) = blockFirst() + val nextEntered = CountDownLatch(1) + val original = provider.onBuild + provider.onBuild = { index -> if (index == 2) nextEntered.countDown(); original(index) } + assertTrue(NetworkChangeMonitor.start()) + request() + assertTrue(entered.await(2, TimeUnit.SECONDS)) + NetworkChangeMonitor.stop() + assertTrue(NetworkChangeMonitor.start()) + request() + assertFalse("overlapping config workers", nextEntered.await(200, TimeUnit.MILLISECONDS)) + release.countDown() + provider.joinWorkers() + loop.dispatchAll() + assertTrue(NativeLib.socketUpdates.isEmpty()) + loop.advanceBy(500) + awaitUpdates(1) + assertEquals(listOf("config-2"), NativeLib.socketUpdates) + assertEquals(1, provider.maximumConcurrentBuilds.get()) + } + + @Test fun coreStartedInvalidatesSnapshotAlreadyBeingPrepared() { + val (entered, release) = blockFirst() + assertTrue(NetworkChangeMonitor.start()) + request() + assertTrue(entered.await(2, TimeUnit.SECONDS)) + NetworkChangeMonitor.onCoreStarted() + release.countDown() + provider.joinWorkers() + loop.dispatchAll() + assertTrue("previous core snapshot was posted", NativeLib.socketUpdates.isEmpty()) + loop.advanceBy(500) + awaitUpdates(1) + assertEquals(listOf("config-2"), NativeLib.socketUpdates) + } + + @Test fun eventsDuringBuildProduceOneAdditionalSnapshot() { + val (entered, release) = blockFirst() + assertTrue(NetworkChangeMonitor.start()) + request() + assertTrue(entered.await(2, TimeUnit.SECONDS)) + repeat(4) { request() } + release.countDown() + provider.joinWorkers() + loop.dispatchAll() + assertEquals(listOf("config-1"), NativeLib.socketUpdates) + loop.advanceBy(500) + awaitUpdates(2) + assertEquals(listOf("config-1", "config-2"), NativeLib.socketUpdates) + assertEquals(1, provider.maximumConcurrentBuilds.get()) + } + + @Test fun coreNotReadyWaitsForStartedEvent() { + assertTrue(NetworkChangeMonitor.start()) + NativeLib.ready = false + request() + assertEquals(0, provider.builds.get()) + NativeLib.ready = true + NetworkChangeMonitor.onCoreStarted() + loop.dispatchAll() + loop.advanceBy(500) + awaitUpdates(1) + } + + @Test fun preparationFailureIsLoggedAndNextEventCanRetry() { + val entered = CountDownLatch(1) + provider.onBuild = { index -> + entered.countDown() + if (index == 1) throw IOException("fixture config failure") else "config-$index" + } + assertTrue(NetworkChangeMonitor.start()) + request() + assertTrue(entered.await(2, TimeUnit.SECONDS)) + provider.joinWorkers() + loop.dispatchAll() + assertTrue(LogManager.entries.any { it.level == "ERROR" && it.message.contains("config") }) + assertTrue(NativeLib.socketUpdates.isEmpty()) + request() + awaitUpdates(1) + assertEquals(listOf("config-2"), NativeLib.socketUpdates) + } + + @Test fun stopUsesManagerOwningRegistration() { + assertTrue(NetworkChangeMonitor.start()) + context.connectivityManager = ConnectivityManager() + NetworkChangeMonitor.stop() + assertTrue(manager.callbacks.isEmpty()) + } + + @Test fun readySnapshotIsNotPostedAfterCoreStops() { + val (entered, release) = blockFirst() + assertTrue(NetworkChangeMonitor.start()) + request() + assertTrue(entered.await(2, TimeUnit.SECONDS)) + NativeLib.ready = false + release.countDown() + provider.joinWorkers() + loop.dispatchAll() + assertTrue(NativeLib.socketUpdates.isEmpty()) + assertEquals(0, NativeLib.networkNotifications.get()) + NativeLib.ready = true + NetworkChangeMonitor.onCoreStarted() + loop.dispatchAll() + loop.advanceBy(500) + awaitUpdates(1) + assertEquals(listOf("config-2"), NativeLib.socketUpdates) + } + + @Test fun rejectedJniUpdateIsLoggedAndNextEventCanRetry() { + NativeLib.acceptSocketUpdate = false + assertTrue(NetworkChangeMonitor.start()) + request() + awaitUpdates(1) + assertTrue(LogManager.entries.any { it.level == "ERROR" && it.message.contains("rejected") }) + NativeLib.acceptSocketUpdate = true + request() + awaitUpdates(2) + assertEquals(listOf("config-1", "config-2"), NativeLib.socketUpdates) + } + + @Test fun registrationFailureIsLoggedAndRetryable() { + manager.failAdd = true + assertFalse(NetworkChangeMonitor.start()) + assertTrue(LogManager.entries.any { it.level == "ERROR" && it.category == "NET" }) + manager.failAdd = false + assertTrue(NetworkChangeMonitor.start()) + } +}