diff --git a/tests/test_android_replies.c b/tests/test_android_replies.c index 2b380dcc..f12fee2c 100644 --- a/tests/test_android_replies.c +++ b/tests/test_android_replies.c @@ -231,6 +231,12 @@ static int check_dm_submissions(void) { struct UTUN_INSTANCE* saved = instance; instance = NULL; rc = utun_bridge_dm_send(conv, text, sizeof(text) - 1, generation, 17); instance = saved; REQUIRE(rc == -1); + REQUIRE(utun_bridge_call_start(NULL, node, 1) == -1 && utun_bridge_call_start("7", 0, 1) == -1); + REQUIRE(utun_bridge_call_accept(0) == -1 && utun_bridge_call_decline(0) == -1 && utun_bridge_call_hangup(0) == -1); + instance = NULL; + REQUIRE(utun_bridge_call_start("7", node, 1) == -1 && utun_bridge_call_accept(1) == -1); + REQUIRE(utun_bridge_call_decline(1) == -1 && utun_bridge_call_hangup(1) == -1); + instance = saved; uasync_poll(async, 0); REQUIRE(!results[15] && !results[16] && !results[17]); printf("[PASS] Android PM: UTF-8, commit ACK, stale generation, SQL rollback/retry and rejected requests\n"); return 0; diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index 26119d2b..63d52cb9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt @@ -320,7 +320,7 @@ object CallController : TelecomCallManager.Listener { /** Ошибка постановки не удерживает FGS/audio, но сохраняет команду и запрещает новый media до queue ACK. */ private fun queueEnd(id: Long, decline: Boolean) { - if (sendEnd(id, decline)) return + if (sendEnd(id, decline)) { pendingEnds.value = pendingEnds.value - id; return } pendingEnds.value = pendingEnds.value + (id to decline) if (endRetry?.isActive == true) return endRetry = scope.launch { 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 index 7302e41b..d188d980 100644 --- 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 @@ -41,6 +41,7 @@ class CallServiceLifecycleTest { 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 + NativeLib.rejectedCallCommands = emptySet() AppLanguage.failText = false; AppLanguage.language = "en"; NotificationCompat.failBuild = false Handler.rejectImmediate = false; Handler.rejectDelayed = false; PendingIntent.reset() MediaAudioController.beforeIdle = {} @@ -55,6 +56,7 @@ class CallServiceLifecycleTest { gates.forEach { it.countDown() }; MediaAudioController.beforeIdle = {} TelecomCallManager.pending = false NativeLib.failActivityRequest = false + NativeLib.rejectedCallCommands = emptySet() Handler.rejectImmediate = false; Handler.rejectDelayed = false AppLanguage.failText = false; NotificationCompat.failBuild = false; CallVideoEngine.failCreate = false power.failures = emptySet(); power.locks.forEach { it.failures = emptySet() } @@ -109,6 +111,73 @@ class CallServiceLifecycleTest { return service } + @Test fun rejectedOutgoingSignallingEndsAndReleasesReservedService() { + NativeLib.rejectedCallCommands = setOf("start") + val service = create(); val id = request(); start(service, id) + await("rejected call must end") { state?.phase == CallPhase.ENDED } + assertFalse(power.locks.single().held); assertFalse(service.audio.active) + assertFalse(CallController.blocksMedia()); diagnostic("start queue rejected") + } + + @Test fun rejectedAcceptDeclinesTheIncomingCallAndReleasesService() { + val id = 17L + CallController.onEvent(34, ByteBuffer.allocate(18).order(ByteOrder.LITTLE_ENDIAN) + .put(1.toByte()).put('7'.code.toByte()).putLong(99).putLong(id).array()) + assertEquals(CallPhase.INCOMING, state?.phase) + NativeLib.rejectedCallCommands = setOf("accept") + CallController.accept(); val service = create(); start(service, id) + await("rejected answer must end") { state?.phase == CallPhase.ENDED } + assertTrue(NativeLib.callCommands.contains("decline" to id)); assertFalse(power.locks.single().held) + diagnostic("accept queue rejected") + } + + @Test fun rejectedContinueKeepsCallerChoiceAvailableForRetry() { + val service = create(); val id = request(); start(service, id) + await("initial signaling queued") { NativeLib.callCommands.contains("start" to id) } + CallController.onEvent(66, ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(id).array()) + assertEquals(CallPhase.WAIT_DECISION, state?.phase) + NativeLib.rejectedCallCommands = setOf("continue"); CallController.continueCall(id) + assertEquals(CallPhase.WAIT_DECISION, state?.phase); diagnostic("continue queue rejected") + NativeLib.rejectedCallCommands = emptySet(); CallController.continueCall(id) + assertEquals(CallPhase.OUTGOING, state?.phase) + } + + @Test fun rejectedHangupReleasesAudioButWaitsForQueueAckBeforeRestoringRadio() { + RadioAudioEngine.instance().activeGroup.value = 7 + active(); val id = requireNotNull(state).callId + NativeLib.rejectedCallCommands = setOf("hangup"); CallController.end(); dispatcher.scheduler.runCurrent() + assertEquals(CallPhase.ENDED, state?.phase); assertFalse(power.locks.single().held) + assertTrue(CallController.blocksMedia()); assertTrue(RadioAudioEngine.instance().restored.isEmpty()) + val starts = context.serviceStarts.size + CallController.start("7", 101, "Other", mediaConfirmed = true) + assertEquals(starts, context.serviceStarts.size); assertEquals(id, state?.callId) + dispatcher.scheduler.advanceTimeBy(250); dispatcher.scheduler.runCurrent() + assertTrue(NativeLib.callCommands.count { it == "hangup" to id } >= 2) + NativeLib.rejectedCallCommands = emptySet() + dispatcher.scheduler.advanceTimeBy(500); dispatcher.scheduler.runCurrent() + await("radio restored after end queue ACK") { RadioAudioEngine.instance().restored == listOf(7L) } + assertFalse(CallController.blocksMedia()); diagnostic("hangup queue rejected") + } + + @Test fun nativeEndEventCompletesPendingHangupWithoutAnotherCommand() { + active(); val id = requireNotNull(state).callId + NativeLib.rejectedCallCommands = setOf("hangup"); CallController.end() + assertTrue(CallController.blocksMedia()) + CallController.onEvent(38, ByteBuffer.allocate(9).order(ByteOrder.LITTLE_ENDIAN).putLong(id).put(0.toByte()).array()) + dispatcher.scheduler.runCurrent(); assertFalse(CallController.blocksMedia()) + val attempts = NativeLib.callCommands.size + dispatcher.scheduler.advanceTimeBy(250); dispatcher.scheduler.runCurrent() + assertEquals(attempts, NativeLib.callCommands.size) + } + + @Test fun resetCancelsPendingSignallingRetriesBeforeCoreStop() { + active(); NativeLib.rejectedCallCommands = setOf("hangup"); CallController.end() + assertTrue(CallController.blocksMedia()); CallController.reset(); dispatcher.scheduler.runCurrent() + assertFalse(CallController.blocksMedia()); val attempts = NativeLib.callCommands.size + dispatcher.scheduler.advanceTimeBy(30_000); dispatcher.scheduler.runCurrent() + assertEquals(attempts, NativeLib.callCommands.size) + } + @Test fun normalStartupOwnsOneLockReceiverAndForegroundBeforeAudio() { val service = create(); val id = request(); start(service, id) assertTrue(power.locks.single().held); assertFalse(power.locks.single().counted)