Merge remote-tracking branch 'origin/master'

This commit is contained in:
evgeny committed 2026-10-27 15:18:21 +01:00
commit 22460a1259
9 files changed
+664 -87

No files matched your search

@@ -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")
}
}
@@ -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<Pair<String, String>>? = null
private var lastInterfaces: List<Pair<String, String>>? = 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<Pair<String, String>>? = 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<Pair<String, String>>? = 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")
}
@@ -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")
}
}
@@ -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 <T> getSystemService(type: Class<T>): T? {
check(type == ConnectivityManager::class.java)
return type.cast(connectivityManager)
}
fun checkSelfPermission(name: String): Int = if (permissionGranted) 0 else -1
}
@@ -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<OnNetworkActiveListener>()
val history = mutableListOf<OnNetworkActiveListener>()
val callbacks = mutableListOf<NetworkCallback>()
val callbackHistory = mutableListOf<NetworkCallback>()
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" }
}
}
@@ -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<Task>()
@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 }
@@ -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<String>()
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<Thread> = Collections.synchronizedSet(mutableSetOf<Thread>())
@Volatile var interfaces = listOf("wlan0" to "192.0.2.1")
@Volatile var onBuild: (Int) -> String = { "config-$it" }
fun getAvailableInterfaces(): List<Pair<String, String>> { 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" } }
}
}
@@ -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())
}
}
@@ -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<CountDownLatch>()
@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<CountDownLatch, CountDownLatch> {
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())
}
}