diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioOverlayLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioOverlayLifecycleTest.kt new file mode 100644 index 00000000..7395beab --- /dev/null +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/RadioOverlayLifecycleTest.kt @@ -0,0 +1,261 @@ +package com.utun.chat.data + +import android.app.Notification +import android.content.Context +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.MotionEvent +import android.view.View +import androidx.core.app.NotificationCompat +import com.utun.chat.ChatApplication +import com.utun.chat.R +import java.util.concurrent.ExecutorService +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/PTT/PCM/session; Android service/window IPC и его частичные отказы моделируются без устройства. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RadioOverlayLifecycleTest { + private lateinit var context: Context + private lateinit var radio: RadioAudioEngine + private lateinit var io: ExecutorService + private val dispatcher = StandardTestDispatcher() + private val services = mutableListOf() + private var previousEngine: Any? = null + private val wm get() = requireNotNull(context.windowManager) + private val view get() = wm.windows.keys.single() + private val main get() = Looper.getMainLooper() + + private fun static(name: String) = RadioOverlayService::class.java.getDeclaredField(name).apply { isAccessible = true } + private fun engineField(name: String) = RadioAudioEngine::class.java.getDeclaredField(name).apply { isAccessible = true } + + @Before fun prepare() { + assertSame(Looper.getMainLooper(), Looper.myLooper()); Dispatchers.setMain(dispatcher) + context = Context().also { ChatApplication.instance = it }; context.configProvider.radioOverlay = true + AudioManager.shared = AudioManager(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 + NativeLib.radioFailures = emptySet(); NativeLib.radioPullResult = 0; NativeLib.onRadioFeed = {}; NativeLib.onRadioCaptureStart = {} + CallController.blocked = false; MicrophonePermission.requester = null; AndroidMicrophoneCapture.reset() + Settings.overlayAllowed = true; Settings.failOverlayQuery = false + AppLanguage.failContext = false; AppLanguage.failText = false; NotificationCompat.failBuild = false + Handler.rejectImmediate = false; Handler.rejectDelayed = false + for (name in listOf("running", "starting", "pendingStop", "restartAfterStop", "activityVisible", "waitingOverlayPermission")) { + static(name).setBoolean(null, false) + } + static("currentService").set(null, null); static("defaultCenter").set(null, null) + RadioOverlayService.overlayPermissionRequester = null + previousEngine = engineField("inst").get(null) + radio = RadioAudioEngine(); engineField("inst").set(null, radio) + io = engineField("io").get(radio) as ExecutorService + radio.start(7); drain(); main.dispatchAll(); radio.onSettingsApplied(NativeLib.radioRequest, true); drain() + LogManager.entries.clear(); LogManager.console = true + } + + @After fun release() { + wm.failures = emptySet(); context.failServiceStop = false; context.acceptServiceStop = true + context.notificationManager?.failures = emptySet(); AppLanguage.failContext = false; AppLanguage.failText = false + Settings.failOverlayQuery = false; Settings.overlayAllowed = true + RadioOverlayService.overlayPermissionRequester = null + context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings() + RadioOverlayService.stop(context) + services.forEach { it.onDestroy() } + radio.stop().get(5, TimeUnit.SECONDS); main.advanceBy(10_000); drain() + io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS)) + engineField("inst").set(null, previousEngine) + dispatcher.scheduler.advanceUntilIdle(); Dispatchers.resetMain() + assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value) + } + + private fun drain() { io.submit {}.get(5, TimeUnit.SECONDS) } + private fun create(): RadioOverlayService = RadioOverlayService().also { + services.add(it); it.notificationManager = context.notificationManager; it.windowManager = wm + it.permissionGranted = context.permissionGranted + it.onCreate(); dispatcher.scheduler.runCurrent() + } + private fun show(): RadioOverlayService = create().also { it.onStartCommand(null, 0, 1); dispatcher.scheduler.runCurrent() } + private fun diagnostic(fragment: String) = assertTrue("Missing diagnostic: $fragment", + LogManager.entries.any { fragment in it.message && it.level in listOf("ERROR", "WARN") }) + private fun touch(action: Int, time: Long = 100, down: Long = time, x: Float = 0f, target: View = view) = + target.touch(MotionEvent(action, rawX = x, eventTime = time, downTime = down)) + + @Test fun foregroundNotificationNeverReplacesOrRemovesThePhoneServiceNotification() { + val manager = requireNotNull(context.notificationManager); val phone = Notification() + manager.active[1002] = phone + val service = show() + assertNotEquals(1002, service.foregroundId); assertSame(phone, manager.active[1002]) + service.onDestroy(); assertSame(phone, manager.active[1002]) + } + + @Test fun startupChannelAndForegroundFailuresAreLoggedAndRequestStop() { + for (failure in listOf("channel", "foreground", "foreground after", "window")) { + val service = RadioOverlayService().also { services.add(it); it.notificationManager = context.notificationManager; it.windowManager = wm } + when (failure) { + "channel" -> requireNotNull(context.notificationManager).failures = setOf("channel") + "foreground" -> service.failForeground = true + "foreground after" -> service.failForegroundAfter = true + "window" -> service.windowManager = null + } + service.onCreate(); assertTrue(service.stops > 0); diagnostic("service setup failed") + requireNotNull(context.notificationManager).failures = emptySet(); service.onDestroy() + } + } + + @Test fun cancelledStartupCompletesForegroundHandshakeBeforeStopping() { + RadioOverlayService.setActivityVisible(true); assertEquals(1, context.serviceStarts.size) + RadioOverlayService.stop(context) + val service = create() + assertEquals(1, service.foregroundAttempts); assertTrue(service.stops > 0) + service.onDestroy(); assertFalse(static("starting").getBoolean(null)); assertFalse(static("running").getBoolean(null)) + } + + @Test fun backgroundStartIsDeferredUntilAnActivityIsVisible() { + RadioOverlayService.sync(context); assertTrue(context.serviceStarts.isEmpty()) + assertEquals(R.string.overlay_open_app, RadioOverlayService.status.value) + RadioOverlayService.setActivityVisible(true); assertEquals(1, context.serviceStarts.size) + RadioOverlayService.sync(context); assertEquals(1, context.serviceStarts.size) + } + + @Test fun failedServiceRequestCanBeRetried() { + context.failServiceStart = true; RadioOverlayService.setActivityVisible(true) + assertFalse(static("starting").getBoolean(null)); diagnostic("Service start failed") + context.failServiceStart = false; RadioOverlayService.sync(context) + assertEquals(2, context.serviceStarts.size) + } + + @Test fun stopRejectionAndExceptionDoNotLatchPendingStopAndCanBeRetried() { + show(); context.acceptServiceStop = false + RadioOverlayService.stop(context); assertFalse(static("pendingStop").getBoolean(null)); diagnostic("Service stop") + context.failServiceStop = true; RadioOverlayService.stop(context) + assertFalse(static("pendingStop").getBoolean(null)) + context.failServiceStop = false; context.acceptServiceStop = true; RadioOverlayService.stop(context) + assertEquals(3, context.serviceStops.size); assertTrue(static("pendingStop").getBoolean(null)) + } + + @Test fun addFailureAfterItsEffectIsRolledBackWithoutAnOrphanWindow() { + wm.failures = setOf("add after"); show() + assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value); diagnostic("Overlay creation failed") + } + + @Test fun addFailureBeforeItsEffectTreatsMissingWindowAsConfirmedCleanup() { + wm.failures = setOf("add"); show() + assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value) + diagnostic("Overlay creation failed") + } + + @Test fun aFailedRemovalRetainsItsWindowForTheNextSync() { + val service = show(); val old = view; touch(MotionEvent.ACTION_DOWN); assertTrue(radio.isTalking()) + wm.failures = setOf("remove"); context.configProvider.radioOverlay = false + context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + service.onStartCommand(null, 0, 2) + assertFalse(radio.isTalking()); assertTrue(wm.windows.containsKey(old)); assertTrue(RadioOverlayService.isShown.value) + wm.failures = emptySet(); service.onStartCommand(null, 0, 3) + assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value) + } + + @Test fun aRemovalFailureAfterItsEffectCanConfirmAbsenceOnRetry() { + val service = show(); wm.failures = setOf("remove after") + context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + service.onStartCommand(null, 0, 2); assertTrue(wm.windows.isEmpty()) + wm.failures = emptySet(); service.onStartCommand(null, 0, 3) + assertFalse(RadioOverlayService.isShown.value); diagnostic("Overlay removal failed") + } + + @Test fun anOldWindowsTouchesCannotCancelOrHoldTheReplacementPtt() { + val service = show(); val old = view + context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + service.onStartCommand(null, 0, 2) + context.configProvider.radioOverlay = true; service.onStartCommand(null, 0, 3) + touch(MotionEvent.ACTION_DOWN); assertTrue(radio.isTalking()) + touch(MotionEvent.ACTION_CANCEL, target = old); assertTrue(radio.isTalking()) + radio.cancelTalk(); touch(MotionEvent.ACTION_DOWN, target = old); assertFalse(radio.isTalking()) + } + + @Test fun failedDragLayoutCancelsItsPttImmediatelyAndDoesNotEscapeTheListener() { + show(); touch(MotionEvent.ACTION_DOWN); wm.failures = setOf("update") + touch(MotionEvent.ACTION_MOVE, time = 150, down = 100, x = 100f) + assertFalse(radio.isTalking()); diagnostic("Overlay touch failed") + } + + @Test fun cancelClearsLatchedTransmissionImmediately() { + show(); touch(MotionEvent.ACTION_DOWN); touch(MotionEvent.ACTION_UP, time = 130, down = 100) + touch(MotionEvent.ACTION_DOWN, time = 180); touch(MotionEvent.ACTION_UP, time = 210, down = 180) + main.advanceBy(500); assertTrue(radio.isTalking()) + touch(MotionEvent.ACTION_CANCEL, time = 220); assertFalse(radio.isTalking()) + } + + @Test fun normalReleaseKeepsItsTailAndRemovingOverlayPreservesOtherPttOwners() { + val service = show(); touch(MotionEvent.ACTION_DOWN); touch(MotionEvent.ACTION_UP, time = 130, down = 100) + assertTrue(radio.isTalking()); main.advanceBy(251); assertFalse(radio.isTalking()) + assertTrue(radio.talkBegin(RadioPttSource.BUTTON)); touch(MotionEvent.ACTION_DOWN) + service.onDestroy(); assertTrue(radio.isTalking()); radio.cancelTalk() + } + + @Test fun stalePositionCallbackCannotResetAReplacementWindowsPendingPosition() { + val service = show(); val old = view + context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + service.onStartCommand(null, 0, 2); context.configProvider.radioOverlay = true; service.onStartCommand(null, 0, 3) + val replacement = view; old.dispatchAnimations() + RadioOverlayService.setDefaultCenter(400, 500) + assertEquals(1, replacement.animations.size) + replacement.dispatchAnimations(); assertEquals(367, wm.windows.getValue(replacement).x) + } + + @Test fun aDestroyedOwnerRetainsFailedWindowCleanupAndBlocksReplacementUntilRetry() { + val old = show(); wm.failures = setOf("remove"); old.onDestroy() + assertNotNull(static("currentService").get(null)); assertTrue(RadioOverlayService.isShown.value) + RadioOverlayService.setActivityVisible(true); assertTrue(context.serviceStarts.isEmpty()) + wm.failures = emptySet(); RadioOverlayService.sync(context) + assertTrue(wm.windows.isEmpty()); assertEquals(1, context.serviceStarts.size) + } + + @Test fun aLateDestroyFromAnOldInstanceCannotStopTheNewOwnerOrItsPtt() { + val old = show(); old.onDestroy(); val next = show() + touch(MotionEvent.ACTION_DOWN); old.onDestroy() + assertTrue(radio.isTalking()); assertTrue(static("running").getBoolean(null)) + assertSame(next, static("currentService").get(null)); assertTrue(RadioOverlayService.isShown.value) + } + + @Test fun permissionRequesterFailureDoesNotLatchThePendingRequest() { + Settings.overlayAllowed = false; var requests = 0 + RadioOverlayService.overlayPermissionRequester = { requests++; error("settings launcher unavailable") } + RadioOverlayService.requestPermissions(); RadioOverlayService.requestPermissions() + assertEquals(2, requests); assertFalse(static("waitingOverlayPermission").getBoolean(null)) + diagnostic("Overlay permission request failed") + } + + @Test fun languageRefreshFailureDoesNotEscapeOrReplaceTheCurrentWindow() { + show(); val old = view; requireNotNull(context.notificationManager).failures = setOf("notify") + RadioOverlayService.refreshLanguage(); assertSame(old, view); diagnostic("notification refresh failed") + } + + @Test fun revokedMicrophonePermissionCancelsPttBeforeRequestingStop() { + show(); touch(MotionEvent.ACTION_DOWN); context.permissionGranted = false + RadioOverlayService.sync(context) + assertFalse(radio.isTalking()); assertEquals(1, context.serviceStops.size) + diagnostic("Microphone permission revoked") + } + + @Test fun backgroundVadKeepsTheServiceWithoutRequiringAnOverlayPermission() { + context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true) + Settings.overlayAllowed = false; val service = show() + assertTrue(wm.windows.isEmpty()); assertEquals(0, service.stops) + assertTrue(static("running").getBoolean(null)) + } + + @Test fun mainStateMutationsRejectWorkersBeforeStartingOrRequestingPermission() { + val error = AtomicReference() + Thread { try { RadioOverlayService.setActivityVisible(true) } catch (e: Throwable) { error.set(e) } }.apply { start(); join() } + assertTrue(error.get() is IllegalStateException); assertTrue(context.serviceStarts.isEmpty()); diagnostic("requires main thread") + } +}