diff --git a/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt b/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt new file mode 100644 index 00000000..2f8e9387 --- /dev/null +++ b/tools/chatgui-android/call-service-tests/src/test/kotlin/com/utun/chat/data/CallServiceLifecycleTest.kt @@ -0,0 +1,232 @@ +package com.utun.chat.data + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import com.utun.chat.ChatApplication +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящие service/controller: частичные OS-эффекты и отказы media моделируются на границах, без DSP/камеры. */ +@OptIn(ExperimentalCoroutinesApi::class) +class CallServiceLifecycleTest { + private lateinit var context: Context + private val dispatcher = StandardTestDispatcher() + private val services = mutableListOf() + private val main get() = Looper.getMainLooper() + private val power get() = requireNotNull(context.powerManager) + private val manager get() = requireNotNull(context.notificationManager) + private val state get() = CallController.state.value + + @Before fun prepare() { + assertSame(main, Looper.myLooper()); Dispatchers.setMain(dispatcher) + context = Context().also { ChatApplication.instance = it }; AudioManager.shared = AudioManager() + NativeLib.ready = true; NativeLib.failActivityRequest = false; NativeLib.acceptActivityRequest = true + AppLanguage.failText = false; AppLanguage.language = "en"; NotificationCompat.failBuild = false + Handler.rejectImmediate = false; Handler.rejectDelayed = false; PendingIntent.reset() + CallVideoEngine.failCreate = false; CallVideoEngine.created.clear() + CallController.reset(); RadioAudioEngine.instance().activeGroup.value = null; RadioAudioEngine.instance().restored.clear() + NativeLib.callCommands.clear(); LogManager.entries.clear(); LogManager.console = true + CallController.onEvent(12, null) + } + + @After fun release() { + Handler.rejectImmediate = false; Handler.rejectDelayed = false + AppLanguage.failText = false; NotificationCompat.failBuild = false; CallVideoEngine.failCreate = false + power.failures = emptySet(); power.locks.forEach { it.failures = emptySet() } + manager.failures = emptySet(); PendingIntent.created.forEach { it.failCancel = false; it.failCancelAfter = false } + services.forEach { + it.audio.failures = emptySet(); it.video?.failures = emptySet(); it.receiverFailures = emptySet() + it.failForegroundStop = false; it.failStopSelf = false + it.shutdown() + } + CallController.reset(); main.advanceBy(30_000); services.forEach { it.onDestroy() } + dispatcher.scheduler.advanceUntilIdle(); Dispatchers.resetMain() + // Очистка модели после отрицательного контроля старого кода, потерявшего владельцев. + power.locks.filter { it.held }.forEach { it.release() } + services.forEach { service -> service.receivers.keys.toList().forEach { service.unregisterReceiver(it) } } + assertNull(state) + } + + private fun diagnostic(fragment: String) = synchronized(LogManager.entries) { + assertTrue("Missing diagnostic: $fragment", LogManager.entries.any { fragment in it.message && it.level in listOf("WARN", "ERROR") }) + } + private fun field(service: CallService, name: String) = CallService::class.java.getDeclaredField(name) + .apply { isAccessible = true }.get(service) + private fun create(): CallService = CallService().also { + services.add(it); it.notificationManager = manager; it.powerManager = power + it.onCreate() + } + private fun request(): Long { + CallController.start("7", 99, "Alice", mediaConfirmed = true) + return requireNotNull(state).callId + } + private fun start(service: CallService, id: Long = request()) { + assertEquals(CallService.START_NOT_STICKY, service.onStartCommand(Intent().putExtra("call_id", id), 0, 1)) + } + private fun await(message: String, condition: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!condition() && System.nanoTime() < deadline) { dispatcher.scheduler.runCurrent(); main.dispatchAll(); Thread.sleep(2) } + assertTrue(message, condition()) + } + private fun active(): CallService { + val service = create(); val id = request(); start(service, id) + await("service must allow signaling") { NativeLib.callCommands.any { it == "start" to id } } + CallController.onEvent(36, ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(id).array()) + assertEquals(CallPhase.ACTIVE, state?.phase); assertTrue(service.audio.active) + return service + } + + @Test fun normalStartupOwnsOneLockReceiverAndForegroundBeforeAudio() { + val service = create(); val id = request(); start(service, id) + assertTrue(power.locks.single().held); assertFalse(power.locks.single().counted) + assertEquals(1, service.receivers.size); assertEquals(1002, service.foregroundId) + assertFalse(service.audio.active); assertTrue(manager.active.getValue(1002).ongoing) + start(service, id); assertEquals(1, power.locks.size); assertEquals(1, service.foregroundAttempts) + } + + @Test fun staleStartCompletesForegroundHandshakeWithoutTakingTheMicrophoneOrLock() { + val service = create(); start(service, 333) + assertEquals(1, service.foregroundAttempts); assertEquals(4, service.foregroundTypes) + assertTrue(power.locks.isEmpty()); assertTrue(service.stops > 0); assertFalse(manager.active.containsKey(1002)) + } + + @Test fun zeroAndOldEndIntentsCannotEndAnOwnedCall() { + val service = active(); val id = requireNotNull(state).callId + for (stale in listOf(0, id + 1)) { + service.onStartCommand(Intent().setAction("com.utun.chat.action.END_CALL").putExtra("call_id", stale), 0, 2) + assertEquals(CallPhase.ACTIVE, state?.phase) + } + diagnostic("stale END") + } + + @Test fun failedChannelCreationDoesNotEscapeOnCreateAndCanRecoverAtStart() { + manager.failures = setOf("channel"); val service = create(); diagnostic("channel") + manager.failures = emptySet(); start(service) + assertTrue(power.locks.single().held); assertEquals(2, manager.channelAttempts) + } + + @Test fun persistentChannelFailureFinishesTheRequestAndStillStopsTheService() { + manager.failures = setOf("channel"); val service = create(); start(service) + assertEquals(CallPhase.ENDED, state?.phase); assertTrue(service.stops > 0); assertTrue(power.locks.isEmpty()) + } + + @Test fun diagnosticChannelQueryFailureCannotRejectAnOtherwiseValidForegroundStart() { + val service = create(); manager.failures = setOf("channel query") + service.refreshLanguage(); start(service) + assertTrue(power.locks.single().held); diagnostic("channel diagnostics") + } + + @Test fun failedRegistrationAfterItsEffectIsRolledBack() { + val service = create(); service.receiverFailures = setOf("register after"); start(service) + assertTrue(service.receivers.isEmpty()); assertTrue(power.locks.all { !it.held }) + assertEquals(CallPhase.ENDED, state?.phase); diagnostic("start failed") + } + + @Test fun failedAcquireAfterItsEffectIsRolledBack() { + val service = create(); power.failures = setOf("acquire"); start(service) + assertFalse(power.locks.single().held); assertEquals(CallPhase.ENDED, state?.phase) + assertTrue(service.stops > 0) + } + + @Test fun wakeLockReleaseFailureRetainsItsOwnerAndOtherCleanupStillRuns() { + val service = active(); val lock = power.locks.single(); lock.failures = setOf("release") + runCatching { service.shutdown() } + assertSame(lock, field(service, "wakeLock")); assertFalse(service.audio.active); assertTrue(service.receivers.isEmpty()) + lock.failures = emptySet(); service.shutdown(); assertFalse(lock.held); assertNull(field(service, "wakeLock")) + } + + @Test fun receiverRemovalFailureRetainsRegistrationAndRetriesIt() { + val service = active(); service.receiverFailures = setOf("unregister") + runCatching { service.shutdown() } + assertEquals(true, field(service, "powerEventsRegistered")); assertFalse(service.audio.active) + assertFalse(power.locks.single().held) + service.receiverFailures = emptySet(); service.shutdown(); assertTrue(service.receivers.isEmpty()) + assertEquals(false, field(service, "powerEventsRegistered")) + } + + @Test fun aVideoStopFailureDoesNotSkipAudioReceiverOrWakeLockCleanup() { + val service = active(); val video = requireNotNull(service.video); video.failures = setOf("stop") + runCatching { service.shutdown() } + assertSame(video, service.video); assertFalse(service.audio.active); assertFalse(power.locks.single().held) + assertTrue(service.receivers.isEmpty()); video.failures = emptySet(); service.shutdown(); assertNull(service.video) + } + + @Test fun audioStopFailureKeepsItsServiceIdForRetry() { + val service = active(); val id = requireNotNull(state).callId; service.audio.failures = setOf("stop") + runCatching { service.shutdown() } + assertEquals(id, field(service, "callId")); assertFalse(power.locks.single().held); assertTrue(service.receivers.isEmpty()) + service.audio.failures = emptySet(); service.shutdown(); assertFalse(service.audio.active) + } + + @Test fun partialAudioStartFailureIsLoggedAndRolledBackBeforeReturningFalse() { + val service = create(); val id = request(); start(service, id) + service.audio.failures = setOf("start") + assertFalse(service.startAudio(id)); assertFalse(service.audio.active); assertTrue(power.locks.all { !it.held }) + diagnostic("audio start failed") + } + + @Test fun videoConstructorFailureRollsBackTheStartedAudio() { + val service = create(); val id = request(); start(service, id); CallVideoEngine.failCreate = true + assertFalse(service.startAudio(id)); assertFalse(service.audio.active); assertTrue(service.stops > 0) + diagnostic("audio start failed") + } + + @Test fun repeatedAudioStartDoesNotReplaceAnOwnedVideoEngine() { + val service = active(); val video = service.video; val id = requireNotNull(state).callId + assertTrue(service.startAudio(id)); assertSame(video, service.video); assertEquals(1, service.audio.starts) + } + + @Test fun failedCameraPromotionRestoresTheLastConfirmedFlag() { + val service = active(); assertTrue(service.setVideoCapture(true)) + service.failForeground = true; assertFalse(service.setVideoCapture(false)) + assertEquals(true, field(service, "cameraForeground")); diagnostic("camera foreground failed") + } + + @Test fun notificationUpdateErrorsDoNotEscapeOrResurrectAStoppedService() { + val service = active(); manager.failures = setOf("notify") + service.updateNotification(); diagnostic("notification update failed") + manager.failures = emptySet(); service.shutdown(); val posted = manager.posted.size + service.updateNotification(); assertEquals(posted, manager.posted.size) + } + + @Test fun startupFailureCannotSkipCleanupWhenTheControllerCallbackFails() { + val service = create(); val id = request() + // Сбой CoreActivity в finish: сервис не должен зависеть от успешности observer контроллера. + NativeLib.failActivityRequest = true; service.failForeground = true + service.onStartCommand(Intent().putExtra("call_id", id), 0, 1) + assertTrue(service.stops > 0); assertTrue(power.locks.isEmpty()); NativeLib.failActivityRequest = false + } + + @Test fun notificationActionsAreOwnedTokensAndCannotBeReusedByAnotherCall() { + val service = active(); val old = requireNotNull(manager.active.getValue(1002).hangup) + CallController.end(); assertTrue(old.cancelled) + service.onDestroy(); val next = active(); val current = requireNotNull(manager.active.getValue(1002).hangup) + assertNotEquals(old, current); assertTrue(current.send().getLongExtra("call_id", 0) != 0L) + assertTrue(next.audio.active) + } + + @Test fun publicMutationsRejectWorkerThreadsBeforeSideEffects() { + val service = create(); val error = AtomicReference() + Thread { try { service.setVideoCapture(true) } catch (e: Throwable) { error.set(e) } }.apply { start(); join() } + assertTrue(error.get() is IllegalStateException); assertEquals(0, service.foregroundAttempts) + diagnostic("requires main thread") + } +}