Keep radio outcome presentation failures inside the controller lifecycle

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent 8b982efa05
commit 93b174ecde
2 files changed
+31 -11

No files matched your search

@@ -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" }
@@ -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<RadioCallState?>
state.value = RadioCallState(id, "123", 99, peerName, channelName, true, RadioCallPhase.INCOMING)
}
private fun rejectOnWorker(action: () -> Unit) {
val error = AtomicReference<Throwable?>()
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()))
}