Exercise radio call signaling refusals, identity, activation and notification lifetime

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent 93b174ecde
commit 16f52c0136
2 files changed
+292

No files matched your search

@@ -27,6 +27,7 @@ class Intent() {
fun getLongExtra(name: String, default: Long): Long = extras[name] as? Long ?: default
fun getBooleanExtra(name: String, default: Boolean): Boolean = extras[name] as? Boolean ?: default
fun getStringExtra(name: String): String? = extras[name] as? String
fun removeExtra(name: String) { extras.remove(name) }
}
abstract class BroadcastReceiver { abstract fun onReceive(context: Context, intent: Intent) }
@@ -0,0 +1,291 @@
package com.utun.chat.data
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Looper
import android.widget.Toast
import androidx.core.app.NotificationCompat
import com.utun.chat.ChatApplication
import java.nio.ByteBuffer
import java.nio.ByteOrder
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
/** Настоящие controller/уведомления/гудки; только Android/JNI/repository — управляемые границы, без радиоэфира. */
@OptIn(ExperimentalCoroutinesApi::class)
class RadioCallLifecycleTest {
private lateinit var context: Context
private val dispatcher = StandardTestDispatcher()
private val state get() = RadioCallController.state.value
private val calls get() = NativeLib.radioCalls
@Before fun prepare() {
assertSame(Looper.getMainLooper(), Looper.myLooper())
Dispatchers.setMain(dispatcher)
context = Context().also { ChatApplication.instance = it }
AudioManager.shared = AudioManager()
NativeLib.ready = true; NativeLib.failActivityRequest = false; NativeLib.acceptActivityRequest = true
NativeLib.onRadioCall = { true }; CallController.state.value = null; StreamController.captureBlocked = false
ChatRepository.failRadioLookup = false; MicrophonePermission.requester = null
AppLanguage.failText = false; AppLanguage.failContext = false; AppLanguage.language = "en"
NotificationCompat.failBuild = false
RadioCallController.reset(); RadioCallController.setVisible(false)
CoreActivity.visible(false); CoreActivity.call(false); CoreActivity.transfer(false); CoreActivity.radioCall(false)
RingtoneManager.reset(); MediaPlayer.reset(); Toast.shown.clear(); calls.clear()
NativeLib.activityRequests.clear(); CoreActivity.reset()
LogManager.entries.clear(); LogManager.console = true
RadioCallController.onEvent(12, null)
}
@After fun release() {
NativeLib.onRadioCall = { true }; NativeLib.ready = true; NativeLib.failActivityRequest = false
AppLanguage.failText = false; NotificationCompat.failBuild = false; MicrophonePermission.requester = null
context.notificationManager?.failures = emptySet(); ChatRepository.failRadioLookup = false
RadioCallController.reset(); IncomingCallManager.stopRinging(); CallRingback.stop()
CallController.state.value = null; StreamController.captureBlocked = false
dispatcher.scheduler.advanceUntilIdle(); Looper.getMainLooper().advanceBy(10_000)
Dispatchers.resetMain()
assertNull(state); assertNull(RadioCallController.activation.value)
assertFalse(RadioCallController.busy())
}
private fun event(id: Long = 11, group: Long = 7, peer: Long = 99, phase: Int = 2, reason: Int = 0, incoming: Boolean = true) =
ByteBuffer.allocate(27).order(ByteOrder.LITTLE_ENDIAN).putLong(id).putLong(group).putLong(peer)
.put(phase.toByte()).put(reason.toByte()).put(if (incoming) 1.toByte() else 0.toByte()).array()
private fun emit(id: Long = 11, phase: Int = 2, reason: Int = 0, incoming: Boolean = true) =
RadioCallController.onEvent(65, event(id = id, phase = phase, reason = reason, incoming = incoming))
private fun outgoing(): Long {
RadioCallController.start("7", 99)
val id = calls.last { it.action == 1 }.id
emit(id, 1, incoming = false)
assertEquals(RadioCallPhase.OUTGOING, state?.phase)
return id
}
private fun diagnostic(fragment: String) = assertTrue("Missing diagnostic: $fragment",
LogManager.entries.any { fragment in it.message && it.level in listOf("ERROR", "WARN") })
private fun ended(reason: Int) {
assertEquals(RadioCallPhase.ENDED, state?.phase); assertEquals(reason, state?.reason)
assertNull(RadioCallController.activation.value); assertFalse(RadioCallController.busy())
assertFalse(requireNotNull(context.notificationManager).active.containsKey(IncomingCallManager.NOTIF_ID))
}
@Test fun incomingReadyAndAcceptanceWaitForVisibleActivityAndActualAudio() {
emit()
assertEquals(RadioCallPhase.INCOMING, state?.phase); assertEquals("Alice", state?.peerName)
assertEquals(2, calls.single().action); assertTrue(NativeLib.activityRequests.last())
RadioCallController.accept(); assertNull(RadioCallController.activation.value)
RadioCallController.setVisible(true); RadioCallController.accept()
assertEquals(RadioCallPhase.PREPARING, state?.phase); assertEquals(11L, RadioCallController.activation.value?.id)
assertEquals(listOf(2), calls.map { it.action })
RadioCallController.activated(12, true); assertNotNull(RadioCallController.activation.value)
RadioCallController.activated(11, true); assertEquals(3, calls.last().action)
emit(phase = 4, reason = 1); assertNull(state); assertFalse(NativeLib.activityRequests.last())
}
@Test fun outgoingRingingAndAcceptedResultActivateOnlyTheAcceptedChannel() {
val id = outgoing()
assertEquals(7L, calls.first().group); assertEquals(99L, calls.first().peer)
emit(id, 3, incoming = false); assertEquals(RadioCallPhase.RINGING, state?.phase)
emit(id, 4, 1, false); assertEquals(RadioCallPhase.PREPARING, state?.phase)
assertEquals("7", RadioCallController.activation.value?.channelId)
RadioCallController.activated(id, true); assertNull(state); assertNull(RadioCallController.activation.value)
}
@Test fun aQueuedStartAlreadyOwnsBusyUntilTheCorePublishesItsResult() {
RadioCallController.start("7", 99); RadioCallController.start("8", 100)
assertEquals(1, calls.count { it.action == 1 }); assertTrue(RadioCallController.busy())
val request = calls.single()
RadioCallController.onEvent(65, event(request.id, phase = 4, reason = 5, incoming = false))
assertFalse(RadioCallController.busy()); diagnostic("Call finished")
}
@Test fun invalidStartIdentifiersAreLoggedAndNeverReachNative() {
for ((channel, peer) in listOf("bad" to 99L, "0" to 99L, "7" to 0L)) {
LogManager.entries.clear(); RadioCallController.start(channel, peer)
assertTrue(calls.isEmpty()); diagnostic("Invalid call target")
}
}
@Test fun rejectedAndThrowingStartLeaveNoPendingOwner() {
for (throws in listOf(false, true)) {
NativeLib.onRadioCall = { if (throws) error("START failed") else false }
LogManager.entries.clear(); RadioCallController.start("7", 99)
assertFalse(RadioCallController.busy()); assertNull(state); diagnostic("Call command")
}
}
@Test fun rejectedAndThrowingReadyCloseTheIncomingAlert() {
for (throws in listOf(false, true)) {
NativeLib.onRadioCall = { if (it.action != 2) true else if (throws) error("READY failed") else false }
emit(if (throws) 12 else 11)
ended(7); diagnostic("Call command")
}
}
@Test fun aFailedDeclineStillEndsLocallyAndReleasesActivity() {
emit(); NativeLib.onRadioCall = { error("DECLINE failed") }
RadioCallController.end()
ended(2); assertFalse(NativeLib.activityRequests.last()); diagnostic("Call command")
}
@Test fun aRejectedCancelStillEndsLocally() {
val id = outgoing(); NativeLib.onRadioCall = { false }
RadioCallController.end(id); ended(4); diagnostic("Call command")
}
@Test fun blockedCaptureWithRejectedFailDoesNotKeepTheIncomingRingtone() {
emit(); RadioCallController.setVisible(true); StreamController.captureBlocked = true
NativeLib.onRadioCall = { false }; RadioCallController.accept()
assertEquals(6, calls.last().action); ended(7); diagnostic("capture blocked")
}
@Test fun rejectedAudioAcceptanceEndsWithoutReplayingActivation() {
emit(); RadioCallController.setVisible(true); RadioCallController.accept()
NativeLib.onRadioCall = { false }; RadioCallController.activated(11, true)
ended(7); val count = calls.size
RadioCallController.activated(11, true); assertEquals(count, calls.size); diagnostic("Call command")
}
@Test fun failedAudioReportsFailAndNeverAccepts() {
emit(); RadioCallController.setVisible(true); RadioCallController.accept()
RadioCallController.activated(11, false)
assertEquals(6, calls.last().action); assertFalse(calls.any { it.action == 3 })
emit(phase = 4, reason = 7); ended(7)
}
@Test fun busyRejectionDoesNotReplaceTheCurrentInvitation() {
emit(); val original = state; NativeLib.onRadioCall = { false }
emit(12)
assertSame(original, state); assertEquals(7, calls.last().action); diagnostic("Call command")
}
@Test fun duplicateIncomingDoesNotCreateAnotherAlertOrReady() {
emit(); val original = state; val posted = requireNotNull(context.notificationManager).posted.size
repeat(3) { emit() }
assertSame(original, state); assertEquals(posted, requireNotNull(context.notificationManager).posted.size)
assertEquals(1, calls.size)
}
@Test fun malformedEventsCannotOwnUiOrSendCommands() {
val invalid = listOf(null, ByteArray(26), event(id = 0), event(group = 0), event(peer = 0), event(phase = 0),
event(phase = 6), event(reason = 10), event(phase = 1), event(phase = 3), event(phase = 5),
event(phase = 4, reason = 0), event(reason = 1), event().also { it[26] = 2 })
for (data in invalid) {
LogManager.entries.clear(); RadioCallController.onEvent(65, data)
assertNull(state); assertTrue(calls.isEmpty()); diagnostic("Invalid call event")
}
}
@Test fun aMatchingIdWithWrongGroupPeerOrRoleCannotFinishTheCurrentCall() {
emit(); val original = state
for (data in listOf(event(group = 8, phase = 4, reason = 4), event(peer = 100, phase = 4, reason = 4),
event(phase = 4, reason = 4, incoming = false))) {
RadioCallController.onEvent(65, data); assertSame(original, state)
}
diagnostic("identity mismatch")
}
@Test fun lateRingingCannotRegressAnOutgoingAudioActivation() {
val id = outgoing(); emit(id, 4, 1, false)
val activation = RadioCallController.activation.value
emit(id, 3, incoming = false)
assertEquals(RadioCallPhase.PREPARING, state?.phase); assertSame(activation, RadioCallController.activation.value)
}
@Test fun continueRequiresWaitDecisionAndLogsAQueueFailure() {
val id = outgoing(); emit(id, 5, incoming = false)
assertEquals(RadioCallPhase.WAIT_DECISION, state?.phase)
NativeLib.onRadioCall = { false }; RadioCallController.continueCall(id)
ended(5); assertEquals(8, calls.last().action); diagnostic("Call command")
}
@Test fun notificationAnswerWaitsForVisibilityAndCannotAcceptAReplacement() {
emit()
val intent = Intent().putExtra(IncomingCallManager.EXTRA_RADIO_CALL, true)
.putExtra(IncomingCallManager.EXTRA_CALL_ID, 11L).putExtra(IncomingCallManager.EXTRA_ANSWER_RADIO, true)
RadioCallController.notificationIntent(intent); assertNull(RadioCallController.activation.value)
assertFalse(intent.getBooleanExtra(IncomingCallManager.EXTRA_ANSWER_RADIO, false))
RadioCallController.end(); emit(12)
RadioCallController.setVisible(true); assertNull(RadioCallController.activation.value)
RadioCallController.notificationIntent(Intent(intent).putExtra(IncomingCallManager.EXTRA_ANSWER_RADIO, true))
assertNull(RadioCallController.activation.value)
}
@Test fun aPermissionCallbackFromAnOldCallCannotActivateItsReplacement() {
emit(); RadioCallController.setVisible(true); context.permissionGranted = false
var callback: (() -> Unit)? = null
MicrophonePermission.requester = { _, granted -> callback = granted }
RadioCallController.accept(); assertNotNull(callback); assertNull(RadioCallController.activation.value)
RadioCallController.end(); emit(12); context.permissionGranted = true
callback?.invoke(); assertNull(RadioCallController.activation.value)
}
@Test fun outcomeNotificationPreservesForegroundServiceAndPhoneOutcomeNotifications() {
val manager = requireNotNull(context.notificationManager)
val overlay = Notification(); val phone = Notification()
manager.active[1002] = overlay; manager.active[1003] = phone
val id = outgoing(); emit(id, 4, 2, false)
assertSame(overlay, manager.active[1002]); assertSame(phone, manager.active[1003])
assertTrue(manager.active.containsKey(1004))
}
@Test fun outcomePresentationFailuresCannotStrandTheEndedState() {
for (failure in listOf("notify", "builder", "language", "manager")) {
val id = outgoing()
when (failure) {
"notify" -> requireNotNull(context.notificationManager).failures = setOf("notify")
"builder" -> NotificationCompat.failBuild = true
"language" -> AppLanguage.failText = true
"manager" -> context.notificationManager = null
}
emit(id, 4, 2, false); ended(2)
dispatcher.scheduler.advanceUntilIdle(); assertNull(state)
diagnostic("Call result presentation failed")
NotificationCompat.failBuild = false; AppLanguage.failText = false
context.notificationManager = android.app.NotificationManager()
}
}
@Test fun anOldEndedTimerCannotClearTheNextCallAndResetDropsPendingActivation() {
emit(); RadioCallController.end(); dispatcher.scheduler.runCurrent()
emit(12); dispatcher.scheduler.advanceUntilIdle(); assertEquals(12L, state?.id)
RadioCallController.setVisible(true); RadioCallController.accept(); assertNotNull(RadioCallController.activation.value)
RadioCallController.onEvent(13, null); assertNull(state); assertNull(RadioCallController.activation.value)
val count = calls.size; RadioCallController.start("7", 99); assertEquals(count, calls.size)
}
@Test fun repositoryFailureUsesFallbackNamesAndStillAcknowledgesTheCall() {
ChatRepository.failRadioLookup = true; emit()
assertEquals("0x0000000000000063", state?.peerName); assertEquals("7", state?.channelName)
assertEquals(2, calls.last().action); diagnostic("Call names unavailable")
}
@Test fun activityQueueFailureDoesNotSkipReadyOrLocalCleanup() {
NativeLib.failActivityRequest = true; emit()
assertEquals(2, calls.last().action); assertEquals(RadioCallPhase.INCOMING, state?.phase)
RadioCallController.end(); ended(2); diagnostic("Call activity update failed")
NativeLib.failActivityRequest = false; CoreActivity.sync(); assertFalse(NativeLib.activityRequests.last())
}
@Test fun notificationIntentRejectsWorkerBeforeConsumingItsExtras() {
emit()
val intent = Intent().putExtra(IncomingCallManager.EXTRA_RADIO_CALL, true)
.putExtra(IncomingCallManager.EXTRA_CALL_ID, 11L).putExtra(IncomingCallManager.EXTRA_ANSWER_RADIO, true)
val error = AtomicReference<Throwable?>()
Thread { try { RadioCallController.notificationIntent(intent) } catch (e: Throwable) { error.set(e) } }.apply { start(); join() }
assertTrue(error.get() is IllegalStateException)
assertTrue(intent.getBooleanExtra(IncomingCallManager.EXTRA_ANSWER_RADIO, false)); diagnostic("requires main thread")
}
}