Exercise call service startup, partial IPC effects and independent cleanup failures

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent dd9aac79a2
commit 9c70858828
1 file changed
+232
@@ -0,0 +1,232 @@
package com.utun.chat.data
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.utun.chat.ChatApplication
import java.nio.ByteBuffer
import java.nio.ByteOrder
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/controller: частичные OS-эффекты и отказы media моделируются на границах, без DSP/камеры. */
@OptIn(ExperimentalCoroutinesApi::class)
class CallServiceLifecycleTest {
private lateinit var context: Context
private val dispatcher = StandardTestDispatcher()
private val services = mutableListOf<CallService>()
private val main get() = Looper.getMainLooper()
private val power get() = requireNotNull(context.powerManager)
private val manager get() = requireNotNull(context.notificationManager)
private val state get() = CallController.state.value
@Before fun prepare() {
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
AppLanguage.failText = false; AppLanguage.language = "en"; NotificationCompat.failBuild = false
Handler.rejectImmediate = false; Handler.rejectDelayed = false; PendingIntent.reset()
CallVideoEngine.failCreate = false; CallVideoEngine.created.clear()
CallController.reset(); RadioAudioEngine.instance().activeGroup.value = null; RadioAudioEngine.instance().restored.clear()
NativeLib.callCommands.clear(); LogManager.entries.clear(); LogManager.console = true
CallController.onEvent(12, null)
}
@After fun release() {
Handler.rejectImmediate = false; Handler.rejectDelayed = false
AppLanguage.failText = false; NotificationCompat.failBuild = false; CallVideoEngine.failCreate = false
power.failures = emptySet(); power.locks.forEach { it.failures = emptySet() }
manager.failures = emptySet(); PendingIntent.created.forEach { it.failCancel = false; it.failCancelAfter = false }
services.forEach {
it.audio.failures = emptySet(); it.video?.failures = emptySet(); it.receiverFailures = emptySet()
it.failForegroundStop = false; it.failStopSelf = false
it.shutdown()
}
CallController.reset(); main.advanceBy(30_000); services.forEach { it.onDestroy() }
dispatcher.scheduler.advanceUntilIdle(); Dispatchers.resetMain()
// Очистка модели после отрицательного контроля старого кода, потерявшего владельцев.
power.locks.filter { it.held }.forEach { it.release() }
services.forEach { service -> service.receivers.keys.toList().forEach { service.unregisterReceiver(it) } }
assertNull(state)
}
private fun diagnostic(fragment: String) = synchronized(LogManager.entries) {
assertTrue("Missing diagnostic: $fragment", LogManager.entries.any { fragment in it.message && it.level in listOf("WARN", "ERROR") })
}
private fun field(service: CallService, name: String) = CallService::class.java.getDeclaredField(name)
.apply { isAccessible = true }.get(service)
private fun create(): CallService = CallService().also {
services.add(it); it.notificationManager = manager; it.powerManager = power
it.onCreate()
}
private fun request(): Long {
CallController.start("7", 99, "Alice", mediaConfirmed = true)
return requireNotNull(state).callId
}
private fun start(service: CallService, id: Long = request()) {
assertEquals(CallService.START_NOT_STICKY, service.onStartCommand(Intent().putExtra("call_id", id), 0, 1))
}
private fun await(message: String, condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3)
while (!condition() && System.nanoTime() < deadline) { dispatcher.scheduler.runCurrent(); main.dispatchAll(); Thread.sleep(2) }
assertTrue(message, condition())
}
private fun active(): CallService {
val service = create(); val id = request(); start(service, id)
await("service must allow signaling") { NativeLib.callCommands.any { it == "start" to id } }
CallController.onEvent(36, ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(id).array())
assertEquals(CallPhase.ACTIVE, state?.phase); assertTrue(service.audio.active)
return service
}
@Test fun normalStartupOwnsOneLockReceiverAndForegroundBeforeAudio() {
val service = create(); val id = request(); start(service, id)
assertTrue(power.locks.single().held); assertFalse(power.locks.single().counted)
assertEquals(1, service.receivers.size); assertEquals(1002, service.foregroundId)
assertFalse(service.audio.active); assertTrue(manager.active.getValue(1002).ongoing)
start(service, id); assertEquals(1, power.locks.size); assertEquals(1, service.foregroundAttempts)
}
@Test fun staleStartCompletesForegroundHandshakeWithoutTakingTheMicrophoneOrLock() {
val service = create(); start(service, 333)
assertEquals(1, service.foregroundAttempts); assertEquals(4, service.foregroundTypes)
assertTrue(power.locks.isEmpty()); assertTrue(service.stops > 0); assertFalse(manager.active.containsKey(1002))
}
@Test fun zeroAndOldEndIntentsCannotEndAnOwnedCall() {
val service = active(); val id = requireNotNull(state).callId
for (stale in listOf(0, id + 1)) {
service.onStartCommand(Intent().setAction("com.utun.chat.action.END_CALL").putExtra("call_id", stale), 0, 2)
assertEquals(CallPhase.ACTIVE, state?.phase)
}
diagnostic("stale END")
}
@Test fun failedChannelCreationDoesNotEscapeOnCreateAndCanRecoverAtStart() {
manager.failures = setOf("channel"); val service = create(); diagnostic("channel")
manager.failures = emptySet(); start(service)
assertTrue(power.locks.single().held); assertEquals(2, manager.channelAttempts)
}
@Test fun persistentChannelFailureFinishesTheRequestAndStillStopsTheService() {
manager.failures = setOf("channel"); val service = create(); start(service)
assertEquals(CallPhase.ENDED, state?.phase); assertTrue(service.stops > 0); assertTrue(power.locks.isEmpty())
}
@Test fun diagnosticChannelQueryFailureCannotRejectAnOtherwiseValidForegroundStart() {
val service = create(); manager.failures = setOf("channel query")
service.refreshLanguage(); start(service)
assertTrue(power.locks.single().held); diagnostic("channel diagnostics")
}
@Test fun failedRegistrationAfterItsEffectIsRolledBack() {
val service = create(); service.receiverFailures = setOf("register after"); start(service)
assertTrue(service.receivers.isEmpty()); assertTrue(power.locks.all { !it.held })
assertEquals(CallPhase.ENDED, state?.phase); diagnostic("start failed")
}
@Test fun failedAcquireAfterItsEffectIsRolledBack() {
val service = create(); power.failures = setOf("acquire"); start(service)
assertFalse(power.locks.single().held); assertEquals(CallPhase.ENDED, state?.phase)
assertTrue(service.stops > 0)
}
@Test fun wakeLockReleaseFailureRetainsItsOwnerAndOtherCleanupStillRuns() {
val service = active(); val lock = power.locks.single(); lock.failures = setOf("release")
runCatching { service.shutdown() }
assertSame(lock, field(service, "wakeLock")); assertFalse(service.audio.active); assertTrue(service.receivers.isEmpty())
lock.failures = emptySet(); service.shutdown(); assertFalse(lock.held); assertNull(field(service, "wakeLock"))
}
@Test fun receiverRemovalFailureRetainsRegistrationAndRetriesIt() {
val service = active(); service.receiverFailures = setOf("unregister")
runCatching { service.shutdown() }
assertEquals(true, field(service, "powerEventsRegistered")); assertFalse(service.audio.active)
assertFalse(power.locks.single().held)
service.receiverFailures = emptySet(); service.shutdown(); assertTrue(service.receivers.isEmpty())
assertEquals(false, field(service, "powerEventsRegistered"))
}
@Test fun aVideoStopFailureDoesNotSkipAudioReceiverOrWakeLockCleanup() {
val service = active(); val video = requireNotNull(service.video); video.failures = setOf("stop")
runCatching { service.shutdown() }
assertSame(video, service.video); assertFalse(service.audio.active); assertFalse(power.locks.single().held)
assertTrue(service.receivers.isEmpty()); video.failures = emptySet(); service.shutdown(); assertNull(service.video)
}
@Test fun audioStopFailureKeepsItsServiceIdForRetry() {
val service = active(); val id = requireNotNull(state).callId; service.audio.failures = setOf("stop")
runCatching { service.shutdown() }
assertEquals(id, field(service, "callId")); assertFalse(power.locks.single().held); assertTrue(service.receivers.isEmpty())
service.audio.failures = emptySet(); service.shutdown(); assertFalse(service.audio.active)
}
@Test fun partialAudioStartFailureIsLoggedAndRolledBackBeforeReturningFalse() {
val service = create(); val id = request(); start(service, id)
service.audio.failures = setOf("start")
assertFalse(service.startAudio(id)); assertFalse(service.audio.active); assertTrue(power.locks.all { !it.held })
diagnostic("audio start failed")
}
@Test fun videoConstructorFailureRollsBackTheStartedAudio() {
val service = create(); val id = request(); start(service, id); CallVideoEngine.failCreate = true
assertFalse(service.startAudio(id)); assertFalse(service.audio.active); assertTrue(service.stops > 0)
diagnostic("audio start failed")
}
@Test fun repeatedAudioStartDoesNotReplaceAnOwnedVideoEngine() {
val service = active(); val video = service.video; val id = requireNotNull(state).callId
assertTrue(service.startAudio(id)); assertSame(video, service.video); assertEquals(1, service.audio.starts)
}
@Test fun failedCameraPromotionRestoresTheLastConfirmedFlag() {
val service = active(); assertTrue(service.setVideoCapture(true))
service.failForeground = true; assertFalse(service.setVideoCapture(false))
assertEquals(true, field(service, "cameraForeground")); diagnostic("camera foreground failed")
}
@Test fun notificationUpdateErrorsDoNotEscapeOrResurrectAStoppedService() {
val service = active(); manager.failures = setOf("notify")
service.updateNotification(); diagnostic("notification update failed")
manager.failures = emptySet(); service.shutdown(); val posted = manager.posted.size
service.updateNotification(); assertEquals(posted, manager.posted.size)
}
@Test fun startupFailureCannotSkipCleanupWhenTheControllerCallbackFails() {
val service = create(); val id = request()
// Сбой CoreActivity в finish: сервис не должен зависеть от успешности observer контроллера.
NativeLib.failActivityRequest = true; service.failForeground = true
service.onStartCommand(Intent().putExtra("call_id", id), 0, 1)
assertTrue(service.stops > 0); assertTrue(power.locks.isEmpty()); NativeLib.failActivityRequest = false
}
@Test fun notificationActionsAreOwnedTokensAndCannotBeReusedByAnotherCall() {
val service = active(); val old = requireNotNull(manager.active.getValue(1002).hangup)
CallController.end(); assertTrue(old.cancelled)
service.onDestroy(); val next = active(); val current = requireNotNull(manager.active.getValue(1002).hangup)
assertNotEquals(old, current); assertTrue(current.send().getLongExtra("call_id", 0) != 0L)
assertTrue(next.audio.active)
}
@Test fun publicMutationsRejectWorkerThreadsBeforeSideEffects() {
val service = create(); val error = AtomicReference<Throwable?>()
Thread { try { service.setVideoCapture(true) } catch (e: Throwable) { error.set(e) } }.apply { start(); join() }
assertTrue(error.get() is IllegalStateException); assertEquals(0, service.foregroundAttempts)
diagnostic("requires main thread")
}
}