Reproduce overlay partial IPC effects, stale gestures and foreground lifecycle failures
This commit is contained in:
1 parent
2f43407ae4
commit
18cb661c91
1 file changed
+261
+261
@@ -0,0 +1,261 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.R
|
||||
import java.util.concurrent.ExecutorService
|
||||
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/PTT/PCM/session; Android service/window IPC и его частичные отказы моделируются без устройства. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RadioOverlayLifecycleTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var radio: RadioAudioEngine
|
||||
private lateinit var io: ExecutorService
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val services = mutableListOf<RadioOverlayService>()
|
||||
private var previousEngine: Any? = null
|
||||
private val wm get() = requireNotNull(context.windowManager)
|
||||
private val view get() = wm.windows.keys.single()
|
||||
private val main get() = Looper.getMainLooper()
|
||||
|
||||
private fun static(name: String) = RadioOverlayService::class.java.getDeclaredField(name).apply { isAccessible = true }
|
||||
private fun engineField(name: String) = RadioAudioEngine::class.java.getDeclaredField(name).apply { isAccessible = true }
|
||||
|
||||
@Before fun prepare() {
|
||||
assertSame(Looper.getMainLooper(), Looper.myLooper()); Dispatchers.setMain(dispatcher)
|
||||
context = Context().also { ChatApplication.instance = it }; context.configProvider.radioOverlay = true
|
||||
AudioManager.shared = AudioManager(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920
|
||||
NativeLib.radioFailures = emptySet(); NativeLib.radioPullResult = 0; NativeLib.onRadioFeed = {}; NativeLib.onRadioCaptureStart = {}
|
||||
CallController.blocked = false; MicrophonePermission.requester = null; AndroidMicrophoneCapture.reset()
|
||||
Settings.overlayAllowed = true; Settings.failOverlayQuery = false
|
||||
AppLanguage.failContext = false; AppLanguage.failText = false; NotificationCompat.failBuild = false
|
||||
Handler.rejectImmediate = false; Handler.rejectDelayed = false
|
||||
for (name in listOf("running", "starting", "pendingStop", "restartAfterStop", "activityVisible", "waitingOverlayPermission")) {
|
||||
static(name).setBoolean(null, false)
|
||||
}
|
||||
static("currentService").set(null, null); static("defaultCenter").set(null, null)
|
||||
RadioOverlayService.overlayPermissionRequester = null
|
||||
previousEngine = engineField("inst").get(null)
|
||||
radio = RadioAudioEngine(); engineField("inst").set(null, radio)
|
||||
io = engineField("io").get(radio) as ExecutorService
|
||||
radio.start(7); drain(); main.dispatchAll(); radio.onSettingsApplied(NativeLib.radioRequest, true); drain()
|
||||
LogManager.entries.clear(); LogManager.console = true
|
||||
}
|
||||
|
||||
@After fun release() {
|
||||
wm.failures = emptySet(); context.failServiceStop = false; context.acceptServiceStop = true
|
||||
context.notificationManager?.failures = emptySet(); AppLanguage.failContext = false; AppLanguage.failText = false
|
||||
Settings.failOverlayQuery = false; Settings.overlayAllowed = true
|
||||
RadioOverlayService.overlayPermissionRequester = null
|
||||
context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings()
|
||||
RadioOverlayService.stop(context)
|
||||
services.forEach { it.onDestroy() }
|
||||
radio.stop().get(5, TimeUnit.SECONDS); main.advanceBy(10_000); drain()
|
||||
io.shutdown(); assertTrue(io.awaitTermination(5, TimeUnit.SECONDS))
|
||||
engineField("inst").set(null, previousEngine)
|
||||
dispatcher.scheduler.advanceUntilIdle(); Dispatchers.resetMain()
|
||||
assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value)
|
||||
}
|
||||
|
||||
private fun drain() { io.submit {}.get(5, TimeUnit.SECONDS) }
|
||||
private fun create(): RadioOverlayService = RadioOverlayService().also {
|
||||
services.add(it); it.notificationManager = context.notificationManager; it.windowManager = wm
|
||||
it.permissionGranted = context.permissionGranted
|
||||
it.onCreate(); dispatcher.scheduler.runCurrent()
|
||||
}
|
||||
private fun show(): RadioOverlayService = create().also { it.onStartCommand(null, 0, 1); dispatcher.scheduler.runCurrent() }
|
||||
private fun diagnostic(fragment: String) = assertTrue("Missing diagnostic: $fragment",
|
||||
LogManager.entries.any { fragment in it.message && it.level in listOf("ERROR", "WARN") })
|
||||
private fun touch(action: Int, time: Long = 100, down: Long = time, x: Float = 0f, target: View = view) =
|
||||
target.touch(MotionEvent(action, rawX = x, eventTime = time, downTime = down))
|
||||
|
||||
@Test fun foregroundNotificationNeverReplacesOrRemovesThePhoneServiceNotification() {
|
||||
val manager = requireNotNull(context.notificationManager); val phone = Notification()
|
||||
manager.active[1002] = phone
|
||||
val service = show()
|
||||
assertNotEquals(1002, service.foregroundId); assertSame(phone, manager.active[1002])
|
||||
service.onDestroy(); assertSame(phone, manager.active[1002])
|
||||
}
|
||||
|
||||
@Test fun startupChannelAndForegroundFailuresAreLoggedAndRequestStop() {
|
||||
for (failure in listOf("channel", "foreground", "foreground after", "window")) {
|
||||
val service = RadioOverlayService().also { services.add(it); it.notificationManager = context.notificationManager; it.windowManager = wm }
|
||||
when (failure) {
|
||||
"channel" -> requireNotNull(context.notificationManager).failures = setOf("channel")
|
||||
"foreground" -> service.failForeground = true
|
||||
"foreground after" -> service.failForegroundAfter = true
|
||||
"window" -> service.windowManager = null
|
||||
}
|
||||
service.onCreate(); assertTrue(service.stops > 0); diagnostic("service setup failed")
|
||||
requireNotNull(context.notificationManager).failures = emptySet(); service.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun cancelledStartupCompletesForegroundHandshakeBeforeStopping() {
|
||||
RadioOverlayService.setActivityVisible(true); assertEquals(1, context.serviceStarts.size)
|
||||
RadioOverlayService.stop(context)
|
||||
val service = create()
|
||||
assertEquals(1, service.foregroundAttempts); assertTrue(service.stops > 0)
|
||||
service.onDestroy(); assertFalse(static("starting").getBoolean(null)); assertFalse(static("running").getBoolean(null))
|
||||
}
|
||||
|
||||
@Test fun backgroundStartIsDeferredUntilAnActivityIsVisible() {
|
||||
RadioOverlayService.sync(context); assertTrue(context.serviceStarts.isEmpty())
|
||||
assertEquals(R.string.overlay_open_app, RadioOverlayService.status.value)
|
||||
RadioOverlayService.setActivityVisible(true); assertEquals(1, context.serviceStarts.size)
|
||||
RadioOverlayService.sync(context); assertEquals(1, context.serviceStarts.size)
|
||||
}
|
||||
|
||||
@Test fun failedServiceRequestCanBeRetried() {
|
||||
context.failServiceStart = true; RadioOverlayService.setActivityVisible(true)
|
||||
assertFalse(static("starting").getBoolean(null)); diagnostic("Service start failed")
|
||||
context.failServiceStart = false; RadioOverlayService.sync(context)
|
||||
assertEquals(2, context.serviceStarts.size)
|
||||
}
|
||||
|
||||
@Test fun stopRejectionAndExceptionDoNotLatchPendingStopAndCanBeRetried() {
|
||||
show(); context.acceptServiceStop = false
|
||||
RadioOverlayService.stop(context); assertFalse(static("pendingStop").getBoolean(null)); diagnostic("Service stop")
|
||||
context.failServiceStop = true; RadioOverlayService.stop(context)
|
||||
assertFalse(static("pendingStop").getBoolean(null))
|
||||
context.failServiceStop = false; context.acceptServiceStop = true; RadioOverlayService.stop(context)
|
||||
assertEquals(3, context.serviceStops.size); assertTrue(static("pendingStop").getBoolean(null))
|
||||
}
|
||||
|
||||
@Test fun addFailureAfterItsEffectIsRolledBackWithoutAnOrphanWindow() {
|
||||
wm.failures = setOf("add after"); show()
|
||||
assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value); diagnostic("Overlay creation failed")
|
||||
}
|
||||
|
||||
@Test fun addFailureBeforeItsEffectTreatsMissingWindowAsConfirmedCleanup() {
|
||||
wm.failures = setOf("add"); show()
|
||||
assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value)
|
||||
diagnostic("Overlay creation failed")
|
||||
}
|
||||
|
||||
@Test fun aFailedRemovalRetainsItsWindowForTheNextSync() {
|
||||
val service = show(); val old = view; touch(MotionEvent.ACTION_DOWN); assertTrue(radio.isTalking())
|
||||
wm.failures = setOf("remove"); context.configProvider.radioOverlay = false
|
||||
context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
|
||||
service.onStartCommand(null, 0, 2)
|
||||
assertFalse(radio.isTalking()); assertTrue(wm.windows.containsKey(old)); assertTrue(RadioOverlayService.isShown.value)
|
||||
wm.failures = emptySet(); service.onStartCommand(null, 0, 3)
|
||||
assertTrue(wm.windows.isEmpty()); assertFalse(RadioOverlayService.isShown.value)
|
||||
}
|
||||
|
||||
@Test fun aRemovalFailureAfterItsEffectCanConfirmAbsenceOnRetry() {
|
||||
val service = show(); wm.failures = setOf("remove after")
|
||||
context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
|
||||
service.onStartCommand(null, 0, 2); assertTrue(wm.windows.isEmpty())
|
||||
wm.failures = emptySet(); service.onStartCommand(null, 0, 3)
|
||||
assertFalse(RadioOverlayService.isShown.value); diagnostic("Overlay removal failed")
|
||||
}
|
||||
|
||||
@Test fun anOldWindowsTouchesCannotCancelOrHoldTheReplacementPtt() {
|
||||
val service = show(); val old = view
|
||||
context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
|
||||
service.onStartCommand(null, 0, 2)
|
||||
context.configProvider.radioOverlay = true; service.onStartCommand(null, 0, 3)
|
||||
touch(MotionEvent.ACTION_DOWN); assertTrue(radio.isTalking())
|
||||
touch(MotionEvent.ACTION_CANCEL, target = old); assertTrue(radio.isTalking())
|
||||
radio.cancelTalk(); touch(MotionEvent.ACTION_DOWN, target = old); assertFalse(radio.isTalking())
|
||||
}
|
||||
|
||||
@Test fun failedDragLayoutCancelsItsPttImmediatelyAndDoesNotEscapeTheListener() {
|
||||
show(); touch(MotionEvent.ACTION_DOWN); wm.failures = setOf("update")
|
||||
touch(MotionEvent.ACTION_MOVE, time = 150, down = 100, x = 100f)
|
||||
assertFalse(radio.isTalking()); diagnostic("Overlay touch failed")
|
||||
}
|
||||
|
||||
@Test fun cancelClearsLatchedTransmissionImmediately() {
|
||||
show(); touch(MotionEvent.ACTION_DOWN); touch(MotionEvent.ACTION_UP, time = 130, down = 100)
|
||||
touch(MotionEvent.ACTION_DOWN, time = 180); touch(MotionEvent.ACTION_UP, time = 210, down = 180)
|
||||
main.advanceBy(500); assertTrue(radio.isTalking())
|
||||
touch(MotionEvent.ACTION_CANCEL, time = 220); assertFalse(radio.isTalking())
|
||||
}
|
||||
|
||||
@Test fun normalReleaseKeepsItsTailAndRemovingOverlayPreservesOtherPttOwners() {
|
||||
val service = show(); touch(MotionEvent.ACTION_DOWN); touch(MotionEvent.ACTION_UP, time = 130, down = 100)
|
||||
assertTrue(radio.isTalking()); main.advanceBy(251); assertFalse(radio.isTalking())
|
||||
assertTrue(radio.talkBegin(RadioPttSource.BUTTON)); touch(MotionEvent.ACTION_DOWN)
|
||||
service.onDestroy(); assertTrue(radio.isTalking()); radio.cancelTalk()
|
||||
}
|
||||
|
||||
@Test fun stalePositionCallbackCannotResetAReplacementWindowsPendingPosition() {
|
||||
val service = show(); val old = view
|
||||
context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
|
||||
service.onStartCommand(null, 0, 2); context.configProvider.radioOverlay = true; service.onStartCommand(null, 0, 3)
|
||||
val replacement = view; old.dispatchAnimations()
|
||||
RadioOverlayService.setDefaultCenter(400, 500)
|
||||
assertEquals(1, replacement.animations.size)
|
||||
replacement.dispatchAnimations(); assertEquals(367, wm.windows.getValue(replacement).x)
|
||||
}
|
||||
|
||||
@Test fun aDestroyedOwnerRetainsFailedWindowCleanupAndBlocksReplacementUntilRetry() {
|
||||
val old = show(); wm.failures = setOf("remove"); old.onDestroy()
|
||||
assertNotNull(static("currentService").get(null)); assertTrue(RadioOverlayService.isShown.value)
|
||||
RadioOverlayService.setActivityVisible(true); assertTrue(context.serviceStarts.isEmpty())
|
||||
wm.failures = emptySet(); RadioOverlayService.sync(context)
|
||||
assertTrue(wm.windows.isEmpty()); assertEquals(1, context.serviceStarts.size)
|
||||
}
|
||||
|
||||
@Test fun aLateDestroyFromAnOldInstanceCannotStopTheNewOwnerOrItsPtt() {
|
||||
val old = show(); old.onDestroy(); val next = show()
|
||||
touch(MotionEvent.ACTION_DOWN); old.onDestroy()
|
||||
assertTrue(radio.isTalking()); assertTrue(static("running").getBoolean(null))
|
||||
assertSame(next, static("currentService").get(null)); assertTrue(RadioOverlayService.isShown.value)
|
||||
}
|
||||
|
||||
@Test fun permissionRequesterFailureDoesNotLatchThePendingRequest() {
|
||||
Settings.overlayAllowed = false; var requests = 0
|
||||
RadioOverlayService.overlayPermissionRequester = { requests++; error("settings launcher unavailable") }
|
||||
RadioOverlayService.requestPermissions(); RadioOverlayService.requestPermissions()
|
||||
assertEquals(2, requests); assertFalse(static("waitingOverlayPermission").getBoolean(null))
|
||||
diagnostic("Overlay permission request failed")
|
||||
}
|
||||
|
||||
@Test fun languageRefreshFailureDoesNotEscapeOrReplaceTheCurrentWindow() {
|
||||
show(); val old = view; requireNotNull(context.notificationManager).failures = setOf("notify")
|
||||
RadioOverlayService.refreshLanguage(); assertSame(old, view); diagnostic("notification refresh failed")
|
||||
}
|
||||
|
||||
@Test fun revokedMicrophonePermissionCancelsPttBeforeRequestingStop() {
|
||||
show(); touch(MotionEvent.ACTION_DOWN); context.permissionGranted = false
|
||||
RadioOverlayService.sync(context)
|
||||
assertFalse(radio.isTalking()); assertEquals(1, context.serviceStops.size)
|
||||
diagnostic("Microphone permission revoked")
|
||||
}
|
||||
|
||||
@Test fun backgroundVadKeepsTheServiceWithoutRequiringAnOverlayPermission() {
|
||||
context.configProvider.radioOverlay = false; context.configProvider.radioSettings = RadioAutoSettings(enabled = true)
|
||||
Settings.overlayAllowed = false; val service = show()
|
||||
assertTrue(wm.windows.isEmpty()); assertEquals(0, service.stops)
|
||||
assertTrue(static("running").getBoolean(null))
|
||||
}
|
||||
|
||||
@Test fun mainStateMutationsRejectWorkersBeforeStartingOrRequestingPermission() {
|
||||
val error = AtomicReference<Throwable?>()
|
||||
Thread { try { RadioOverlayService.setActivityVisible(true) } catch (e: Throwable) { error.set(e) } }.apply { start(); join() }
|
||||
assertTrue(error.get() is IllegalStateException); assertTrue(context.serviceStarts.isEmpty()); diagnostic("requires main thread")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user