Exercise call service startup, partial IPC effects and independent cleanup failures
This commit is contained in:
1 parent
dd9aac79a2
commit
9c70858828
1 file changed
+232
+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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user