Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/src/main/java/com/papi/nova/Game.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import com.papi.nova.ui.StreamContainer
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.papi.nova.utils.Dialog
import com.papi.nova.utils.DeviceUtils
import com.papi.nova.utils.CompanionControlLifecyclePolicy
import com.papi.nova.utils.ExternalDisplayControlPresentation
import com.papi.nova.utils.GameDisplayLaunchTrampolineActivity
import com.papi.nova.utils.MouseModeOption
Expand Down Expand Up @@ -458,7 +459,14 @@ listenForExternalDisplayRemoval()
}

fun showCompanionControls() {
runOnUiThread { launchCompanionControlsIfAvailable() }
runOnUiThread {
if (!CompanionControlLifecyclePolicy.canShow(isStreamActive, isFinishing(), isDestroyed)) {
LimeLog.info("Nova: Skipping companion controls for inactive Game lifecycle active=$isStreamActive finishing=${isFinishing()} destroyed=$isDestroyed")
closeCompanionControls()
return@runOnUiThread
}
launchCompanionControlsIfAvailable()
}
}

@SuppressLint("InlinedApi")
Expand Down Expand Up @@ -4442,6 +4450,7 @@ if (connecting || connected)
connected = false
connecting = connected
isStreamActive = false
closeCompanionControls()
stopPolarisLiveSessionStatusRefresh()
runtimeTasks.cancel("NovaBitrateAdjust")
// Send AI session report before dismissing HUD
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.papi.nova.utils

internal object CompanionControlLifecyclePolicy {
@JvmStatic
fun canShow(
streamActive: Boolean,
gameFinishing: Boolean,
gameDestroyed: Boolean
): Boolean = streamActive && !gameFinishing && !gameDestroyed
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.papi.nova.utils

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class CompanionControlLifecyclePolicyTest {
@Test
fun activeLiveGameCanShowCompanionControls() {
assertTrue(CompanionControlLifecyclePolicy.canShow(true, false, false))
}

@Test
fun inactiveStreamCannotShowCompanionControls() {
assertFalse(CompanionControlLifecyclePolicy.canShow(false, false, false))
}

@Test
fun finishingGameCannotShowCompanionControls() {
assertFalse(CompanionControlLifecyclePolicy.canShow(true, true, false))
}

@Test
fun destroyedGameCannotShowCompanionControls() {
assertFalse(CompanionControlLifecyclePolicy.canShow(true, false, true))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,47 @@ class NovaExternalDisplayRoutingSourceGuardTest {
)
}

@Test
fun stoppedStreamDismissesCompanionControlsBeforeBackgroundCleanup() {
val source = File("src/main/java/com/papi/nova/Game.kt").readText()
val stopConnection =
source.substringAfter("private fun stopConnection()")
.substringBefore("override fun stageFailed(")
val streamInactive = stopConnection.indexOf("isStreamActive = false")
val controlsClosed = stopConnection.indexOf("closeCompanionControls()", streamInactive)
val backgroundCleanup = stopConnection.indexOf("launchRuntimeIo(\"NovaSessionReport\")")

assertTrue("stopConnection must mark the stream inactive", streamInactive >= 0)
assertTrue("stopConnection must dismiss companion controls after marking the stream inactive", controlsClosed > streamInactive)
assertTrue("companion controls must close before asynchronous teardown work", controlsClosed in 0 until backgroundCleanup)
}

@Test
fun companionReopenChecksLiveGameLifecycleAtExecutionTime() {
val source = File("src/main/java/com/papi/nova/Game.kt").readText()
val showControls =
source.substringAfter("fun showCompanionControls() {")
.substringBefore("@SuppressLint(\"InlinedApi\")")
val uiDispatch = showControls.indexOf("runOnUiThread {")
val lifecycleDecision = showControls.indexOf("CompanionControlLifecyclePolicy.canShow(")
val deniedClose = showControls.indexOf("closeCompanionControls()")
val deniedReturn = showControls.indexOf("return@runOnUiThread")
val presentationLaunch = showControls.indexOf("launchCompanionControlsIfAvailable()")

assertTrue("showCompanionControls must dispatch before reading lifecycle state", uiDispatch >= 0)
assertTrue("lifecycle state must be checked inside the UI-thread callback", lifecycleDecision > uiDispatch)
assertTrue("denied reopen must close stale controls", deniedClose > lifecycleDecision)
assertTrue("denied reopen must return before presentation launch", deniedReturn > deniedClose)
assertTrue("presentation launch must follow the lifecycle gate", presentationLaunch > deniedReturn)
val compactShowControls = showControls.replace(Regex("\\s+"), " ")
assertTrue(
"The execution-time lifecycle decision must receive the live Game state, not constants",
compactShowControls.contains(
"CompanionControlLifecyclePolicy.canShow(isStreamActive, isFinishing(), isDestroyed)"
)
)
}

@Test
fun newlyAddedDisplayReopensCompanionControlsUsingTheNewLogicalDisplayId() {
val game = File("src/main/java/com/papi/nova/Game.kt").readText()
Expand Down
Loading