diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt index 260d96b0..8f88c298 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioCallController.kt @@ -210,15 +210,17 @@ object RadioCallController { private fun showOutcome(reason: Int) { if (reason == 4 || reason == 1) return - val ctx = ChatApplication.instance - val text = AppLanguage.text(reasonText(reason)) - if (visible) Toast.makeText(ctx, text, Toast.LENGTH_LONG).show() - else try { - ctx.getSystemService(NotificationManager::class.java).notify(1002, + try { + val ctx = ChatApplication.instance + val text = AppLanguage.text(reasonText(reason)) + if (visible) { Toast.makeText(ctx, text, Toast.LENGTH_LONG).show(); return } + requireNotNull(ctx.getSystemService(NotificationManager::class.java)) { "notification manager unavailable" }.notify(1002, NotificationCompat.Builder(ctx, IncomingCallManager.CHANNEL_CALL) .setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle(AppLanguage.text(R.string.radio_call)) .setContentText(text).setAutoCancel(true).build()) - } catch (e: SecurityException) { LogManager.addLog("WARN", "radio", "Call result notification denied: $e") } + } catch (e: Exception) { + LogManager.addLog("ERROR", "radio", "Call result presentation failed reason=$reason visible=$visible: $e") + } } private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "RadioCallController requires main thread" } diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/IncomingCallLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/IncomingCallLifecycleTest.kt index 1929cdf5..306bfcdc 100644 --- a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/IncomingCallLifecycleTest.kt +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/IncomingCallLifecycleTest.kt @@ -16,18 +16,27 @@ import com.utun.chat.ChatApplication import com.utun.chat.MainActivity import com.utun.chat.R import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +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 /** Настоящие manager/receiver/ringtone: identity OS tokens, отказы служб и жизненный цикл входящего оповещения. */ +@OptIn(ExperimentalCoroutinesApi::class) class IncomingCallLifecycleTest { private lateinit var context: Context private lateinit var notifications: NotificationManager private lateinit var power: PowerManager private val receiver = IncomingCallActionReceiver() private val current get() = requireNotNull(notifications.active[IncomingCallManager.NOTIF_ID]) + private val dispatcher = StandardTestDispatcher() + private val radioEndings get() = NativeLib.radioCalls.filter { it.action == 4 }.map { it.id } @Before fun prepare() { assertSame(Looper.getMainLooper(), Looper.myLooper()) @@ -38,7 +47,8 @@ class IncomingCallLifecycleTest { Build.VERSION.SDK_INT = 36 AppLanguage.language = "en"; AppLanguage.failContext = false; AppLanguage.failText = false CallController.media = false; CallController.endings.clear() - RadioCallController.state.value = null; RadioCallController.endings.clear() + Dispatchers.setMain(dispatcher) + RadioCallController.reset(); NativeLib.radioCalls.clear(); NativeLib.onRadioCall = { true } NotificationCompat.failBuild = false PendingIntent.reset(); RingtoneManager.reset(); MediaPlayer.reset() // Процесс приложения живёт один раз; JVM-suite моделирует новый Application между сценариями. @@ -52,6 +62,7 @@ class IncomingCallLifecycleTest { power.failures = emptySet(); power.locks.forEach { it.failures = emptySet() } PendingIntent.created.forEach { it.failCancel = false; it.failCancelAfter = false } IncomingCallManager.stopRinging() + RadioCallController.reset(); dispatcher.scheduler.advanceUntilIdle(); Dispatchers.resetMain() Looper.getMainLooper().advanceBy(10_000) Build.VERSION.SDK_INT = 36 } @@ -64,6 +75,12 @@ class IncomingCallLifecycleTest { private fun isRadio(intent: Intent) = intent.getBooleanExtra(IncomingCallManager.EXTRA_RADIO_CALL, false) private fun deliver(intent: Intent) = receiver.onReceive(context, intent) private fun show(id: Long = 11L, radio: Boolean = false) = IncomingCallManager.show(id, "123", 99L, radio) + private fun setRadioCall(id: Long, peerName: String = "Alice", channelName: String = "Team") { + @Suppress("UNCHECKED_CAST") + val state = RadioCallController::class.java.getDeclaredField("_state").apply { isAccessible = true } + .get(null) as MutableStateFlow + state.value = RadioCallState(id, "123", 99, peerName, channelName, true, RadioCallPhase.INCOMING) + } private fun rejectOnWorker(action: () -> Unit) { val error = AtomicReference() Thread({ try { action() } catch (e: Throwable) { error.set(e) } }, "incoming-test-worker").apply { start(); join() } @@ -145,7 +162,7 @@ class IncomingCallLifecycleTest { } @Test fun radioAlertPreservesPeerNamesAndDistinctAnswerAction() { - RadioCallController.state.value = HostRadioCallState("Alice", "Team") + setRadioCall(11) show(radio = true) assertEquals("Alice · Team", current.text) assertEquals("en:${R.string.radio_call_incoming}", current.title) @@ -271,11 +288,12 @@ class IncomingCallLifecycleTest { val intent = decline().send() repeat(3) { deliver(intent) } assertEquals(listOf(11L to 2), CallController.endings) - assertTrue(RadioCallController.endings.isEmpty()) + assertTrue(radioEndings.isEmpty()) + setRadioCall(22) show(22, radio = true) val radioIntent = decline().send() repeat(3) { deliver(radioIntent) } - assertEquals(listOf(22L), RadioCallController.endings) + assertEquals(listOf(22L), radioEndings) assertEquals(1, CallController.endings.size) } @@ -296,7 +314,7 @@ class IncomingCallLifecycleTest { val wrongKind = decline().send().putExtra(IncomingCallManager.EXTRA_RADIO_CALL, true) deliver(wrongKind) assertTrue(CallController.endings.isEmpty()) - assertTrue(RadioCallController.endings.isEmpty()) + assertTrue(radioEndings.isEmpty()) assertEquals(22L, callId(decline().send())) }