Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
22460a1259
9 files changed
+664
-87
No files matched your search
+43
-27
@@ -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")
|
||||
}
|
||||
}
|
||||
+89
-53
@@ -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 }
|
||||
+32
@@ -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" } }
|
||||
}
|
||||
}
|
||||
+148
@@ -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())
|
||||
}
|
||||
}
|
||||
+251
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user