Skip to content

Commit 7fc8205

Browse files
committed
Fix scenes expiration
1 parent e1068e7 commit 7fc8205

7 files changed

Lines changed: 94 additions & 62 deletions

File tree

core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneAutomationApi.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,29 @@ interface SceneAutomationApi {
4040
/** Set the enabled flag on a scene by id. Fails only if the scene is missing. */
4141
fun setEnabled(id: String, enabled: Boolean): SceneAutomationResult
4242

43-
/** Whether a scene is currently active (running, not yet expired). */
43+
/**
44+
* Whether a scene is currently active (running, not yet expired).
45+
*
46+
* Asks "is a scene in force right now", so an expired scene whose effects are already reverted
47+
* does NOT count, even while its "ended" banner is still on screen. This is the question the
48+
* automation gates ask (`TriggerSceneActive`, and through it `ActionRunScene`'s precondition);
49+
* counting the banner there would block them until the user happens to dismiss it.
50+
*
51+
* For "is there a scene the user can act on", use [hasSceneToStop] instead.
52+
*/
4453
fun isAnySceneActive(): Boolean
4554

55+
/**
56+
* Whether [stopActiveScene] has something to act on: a scene that is running, or one that has
57+
* expired but whose banner has not been dismissed yet (dismissing it is a valid stop).
58+
*
59+
* Deliberately wider than [isAnySceneActive], and the same condition [activeFlow] emits — the
60+
* wear tile is driven by both, so they must agree.
61+
*/
62+
fun hasSceneToStop(): Boolean
63+
4664
/** Emits true when there's a stoppable scene state — running OR expired-with-banner.
47-
* Used by wear-sync to drive the tile's stop button visibility. */
65+
* Used by wear-sync to drive the tile's stop button visibility. Matches [hasSceneToStop]. */
4866
val activeFlow: Flow<Boolean>
4967

5068
/** End the active scene (deactivate) or dismiss the expired banner. No-op if nothing active. */

implementation/src/main/kotlin/app/aaps/implementation/scenes/ActiveSceneManager.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ class ActiveSceneManager @Inject constructor(
6969

7070
/** Set the active scene state (called by SceneExecutor on activation) */
7171
fun setActive(state: ActiveSceneState) {
72-
aapsLogger.info(LTag.UI, "XXXX ActiveSceneManager.setActive('${state.scene.name}')")
7372
_activeSceneState.value = state
7473
persistActiveState(state)
7574
}
@@ -79,15 +78,13 @@ class ActiveSceneManager @Inject constructor(
7978
fun markExpired() {
8079
val current = _activeSceneState.value ?: return
8180
if (current.lifecycle == SceneLifecycle.EXPIRED) return
82-
aapsLogger.info(LTag.UI, "XXXX ActiveSceneManager.markExpired() — scene='${current.scene.name}'")
8381
val updated = current.copy(lifecycle = SceneLifecycle.EXPIRED)
8482
_activeSceneState.value = updated
8583
persistActiveState(updated)
8684
}
8785

8886
/** Clear the active scene (called by SceneExecutor on deactivation or dismiss) */
8987
fun clearActive() {
90-
aapsLogger.info(LTag.UI, "XXXX ActiveSceneManager.clearActive() — was='${_activeSceneState.value?.scene?.name}'")
9188
_activeSceneState.value = null
9289
preferences.put(StringNonKey.ActiveScene, "")
9390
}

implementation/src/main/kotlin/app/aaps/implementation/scenes/SceneAutomationApiImpl.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class SceneAutomationApiImpl @Inject constructor(
2828

2929
override val scenesFlow: StateFlow<String> get() = sceneRepository.scenesFlow
3030

31-
override fun isAnySceneActive(): Boolean = activeSceneManager.isActive()
31+
// An expired scene keeps its slot until the user dismisses the banner, so "a scene exists" and
32+
// "a scene is in force" are different questions. isActive() answers the first one.
33+
override fun isAnySceneActive(): Boolean = activeSceneManager.isActive() && !activeSceneManager.isExpired()
34+
35+
override fun hasSceneToStop(): Boolean = activeSceneManager.isActive()
3236

3337
override val activeFlow: Flow<Boolean> =
3438
activeSceneManager.activeSceneState.map { it != null }.distinctUntilChanged()

implementation/src/main/kotlin/app/aaps/implementation/scenes/SceneExecutor.kt

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ class SceneExecutor @Inject constructor(
161161
* @return Result of the execution
162162
*/
163163
suspend fun activate(scene: Scene, durationMinutes: Int = scene.defaultDurationMinutes): SceneExecutionResult {
164-
aapsLogger.info(LTag.UI, "XXXX activate() entry scene='${scene.name}' id=${scene.id} duration=${durationMinutes}min actions=${scene.actions.size}")
165164

166165
// Defensive precondition gate — covers automation rules and races
167166
// where UI state lagged behind reality. UI normally disables the entry
@@ -181,41 +180,24 @@ class SceneExecutor @Inject constructor(
181180
// scheduleExpiryWorker handles any leftover work.
182181
if (activeSceneManager.isActive()) {
183182
val previouslyExpired = activeSceneManager.isExpired()
184-
aapsLogger.info(LTag.UI, "XXXX activate() — winding down previous active scene '${activeSceneManager.getActiveState()?.scene?.name}' expired=$previouslyExpired")
185183
if (!previouslyExpired) {
186184
deactivate()
187185
} else {
188186
activeSceneManager.clearActive()
189187
}
190188
} else {
191-
aapsLogger.info(LTag.UI, "XXXX activate() — no previous active scene")
192189
}
193190

194191
val now = dateUtil.now()
195192
val durationMs = T.mins(durationMinutes.toLong()).msecs()
196-
aapsLogger.info(LTag.UI, "XXXX activate() now=$now durationMs=$durationMs")
197193

198194
// Capture pre-activation SMB flag (the only "truly prior" state) before making changes.
199-
aapsLogger.info(LTag.UI, "XXXX activate() calling capturePriorSmb()")
200-
val priorSmb = try {
201-
capturePriorSmb(scene)
202-
} catch (e: Throwable) {
203-
aapsLogger.error(LTag.UI, "XXXX activate() capturePriorSmb FAILED", e)
204-
throw e
205-
}
206-
aapsLogger.info(LTag.UI, "XXXX activate() capturePriorSmb() returned: $priorSmb")
195+
val priorSmb = capturePriorSmb(scene)
207196

208197
// Execute each action
209198
val actionResults = mutableListOf<SceneExecutionResult.ActionResult>()
210-
for ((idx, action) in scene.actions.withIndex()) {
211-
aapsLogger.info(LTag.UI, "XXXX activate() executing action $idx/${scene.actions.size}: ${action::class.simpleName}")
212-
val result = try {
213-
executeAction(action, durationMinutes, now)
214-
} catch (e: Throwable) {
215-
aapsLogger.error(LTag.UI, "XXXX activate() executeAction #$idx FAILED", e)
216-
throw e
217-
}
218-
aapsLogger.info(LTag.UI, "XXXX activate() action $idx result: success=${result.success} err=${result.errorMessage}")
199+
for (action in scene.actions) {
200+
val result = executeAction(action, durationMinutes, now)
219201
actionResults.add(result)
220202
}
221203

@@ -235,15 +217,12 @@ class SceneExecutor @Inject constructor(
235217
priorSmb = priorSmb,
236218
scopedRecords = scopedRecords
237219
)
238-
aapsLogger.info(LTag.UI, "XXXX activate() setting active state for '${scene.name}'")
239220
activeSceneManager.setActive(activeState)
240221

241222
// Schedule expiry notification if duration-based
242223
if (durationMs > 0) {
243-
aapsLogger.info(LTag.UI, "XXXX activate() scheduling expiry worker in ${durationMs}ms")
244224
scheduleExpiryWorker(scene.name, durationMs)
245225
} else {
246-
aapsLogger.info(LTag.UI, "XXXX activate() durationMs==0, no expiry worker scheduled (indefinite)")
247226
}
248227

249228
// Log user entry
@@ -308,13 +287,10 @@ class SceneExecutor @Inject constructor(
308287
* Marks the scene as expired so the banner shows "Dismiss" instead of "End Scene".
309288
*/
310289
suspend fun onExpiry() {
311-
aapsLogger.info(LTag.UI, "XXXX onExpiry() entry")
312290
val activeState = activeSceneManager.getActiveState()
313291
if (activeState == null) {
314-
aapsLogger.info(LTag.UI, "XXXX onExpiry() — no active state, returning")
315292
return
316293
}
317-
aapsLogger.info(LTag.UI, "XXXX onExpiry() scene='${activeState.scene.name}' endAction=${activeState.scene.endAction}")
318294

319295
val now = dateUtil.now()
320296

@@ -330,14 +306,12 @@ class SceneExecutor @Inject constructor(
330306
// TT / LoopMode / CarePortal records self-expire via their own timestamp+duration queries.
331307
for (action in activeState.scene.actions) {
332308
if (action is SceneAction.SmbToggle || action is SceneAction.ProfileSwitch) {
333-
aapsLogger.info(LTag.UI, "XXXX onExpiry() reverting ${action::class.simpleName}")
334309
revertAction(action, activeState, now)
335310
}
336311
}
337312

338313
// Mark as expired (keep state for banner display) instead of clearing.
339314
// Lifecycle change rides the existing RunningConfigurationPublisher cycle to clients.
340-
aapsLogger.info(LTag.UI, "XXXX onExpiry() calling markExpired()")
341315
activeSceneManager.markExpired()
342316

343317
// Log

implementation/src/main/kotlin/app/aaps/implementation/scenes/SceneExpiryWorker.kt

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,52 +49,39 @@ class SceneExpiryWorker @AssistedInject constructor(
4949
return Result.retry()
5050
}
5151
val sceneName = inputData.getString(KEY_SCENE_NAME) ?: "Scene"
52-
aapsLogger.info(LTag.UI, "XXXX SceneExpiryWorker fired for '$sceneName'")
5352
val activeState = activeSceneManager.getActiveState()
5453
if (activeState == null) {
55-
aapsLogger.info(LTag.UI, "XXXX no active state — worker exiting")
5654
return Result.success()
5755
}
5856
// If a previous run already expired this scene (e.g. WorkManager retried
5957
// after we returned Result.retry from the init gate), skip — re-running
6058
// onExpiry could double-activate a chained scene.
6159
if (activeSceneManager.isExpired()) {
62-
aapsLogger.info(LTag.UI, "XXXX scene already expired — worker exiting")
6360
return Result.success()
6461
}
65-
aapsLogger.info(LTag.UI, "XXXX active=${activeState.scene.name} id=${activeState.scene.id} endAction=${activeState.scene.endAction}")
6662

6763
val endAction = activeState.scene.endAction
68-
aapsLogger.info(LTag.UI, "XXXX calling onExpiry() — reverting non-duration actions")
6964
sceneExecutor.onExpiry()
70-
aapsLogger.info(LTag.UI, "XXXX onExpiry() returned; activeState now=${activeSceneManager.getActiveState()?.scene?.name} expired=${activeSceneManager.isActive()}")
7165

7266
if (endAction is SceneEndAction.ChainScene) {
73-
aapsLogger.info(LTag.UI, "XXXX endAction is ChainScene → targetId=${endAction.sceneId}")
7467
runChain(sceneName, endAction.sceneId)
7568
} else {
76-
aapsLogger.info(LTag.UI, "XXXX no chain configured (endAction=$endAction) → posting ended notification")
7769
postEndedNotification(sceneName)
7870
}
7971

80-
aapsLogger.info(LTag.UI, "XXXX SceneExpiryWorker finishing; final activeState=${activeSceneManager.getActiveState()?.scene?.name}")
8172
return Result.success()
8273
}
8374

8475
private suspend fun runChain(endedName: String, targetId: String) {
85-
aapsLogger.info(LTag.UI, "XXXX runChain from '$endedName' to targetId=$targetId")
8676
val target = sceneRepository.getScene(targetId)
8777
if (target == null) {
88-
aapsLogger.info(LTag.UI, "XXXX target $targetId NOT FOUND in repository — skipping")
8978
postEndedWithSkip(endedName, rh.gs(R.string.scene_chain_skipped_deleted))
9079
return
9180
}
92-
aapsLogger.info(LTag.UI, "XXXX target resolved: name='${target.name}' enabled=${target.isEnabled} actions=${target.actions.size} duration=${target.defaultDurationMinutes}min")
9381

9482
val loopSuspended = loop.runningMode().pausesLoopExecution()
9583
val pumpInit = activePlugin.activePump.isInitialized()
9684
val profile = profileFunction.getProfile()
97-
aapsLogger.info(LTag.UI, "XXXX preconditions: loopSuspended=$loopSuspended pumpInit=$pumpInit profile=${profile != null}")
9885

9986
val skipReason: String? = when {
10087
!target.isEnabled -> rh.gs(R.string.scene_chain_skipped_disabled, target.name)
@@ -104,21 +91,11 @@ class SceneExpiryWorker @AssistedInject constructor(
10491
}
10592

10693
if (skipReason != null) {
107-
aapsLogger.info(LTag.UI, "XXXX chain SKIPPED: $skipReason")
10894
postEndedWithSkip(endedName, skipReason)
10995
return
11096
}
11197

112-
aapsLogger.info(LTag.UI, "XXXX calling sceneExecutor.activate('${target.name}')")
113-
val result = try {
114-
sceneExecutor.activate(target)
115-
} catch (e: Throwable) {
116-
aapsLogger.error(LTag.UI, "XXXX sceneExecutor.activate('${target.name}') THREW", e)
117-
throw e
118-
}
119-
aapsLogger.info(LTag.UI, "XXXX activate() returned: success=${result.success} error=${result.errorMessage}")
120-
aapsLogger.info(LTag.UI, "XXXX actionResults: ${result.actionResults.joinToString { "${it.action::class.simpleName}=${it.success}${it.errorMessage?.let { e -> "($e)" } ?: ""}" }}")
121-
aapsLogger.info(LTag.UI, "XXXX post-activate activeState=${activeSceneManager.getActiveState()?.scene?.name}")
98+
val result = sceneExecutor.activate(target)
12299

123100
if (result.success) {
124101
postChainSuccess(endedName, target.name)
@@ -127,7 +104,7 @@ class SceneExpiryWorker @AssistedInject constructor(
127104
val details = failed.joinToString("; ") {
128105
"${it.action::class.simpleName}${it.errorMessage?.let { e -> ": $e" } ?: ""}"
129106
}
130-
aapsLogger.error(LTag.UI, "XXXX chain '$endedName' → '${target.name}' partial failure — ${failed.size}/${result.actionResults.size} actions failed: $details")
107+
aapsLogger.error(LTag.UI, "Scene chain '$endedName' → '${target.name}' partial failure — ${failed.size}/${result.actionResults.size} actions failed: $details")
131108
postChainError(endedName, target.name, failed.size, result.actionResults.size, details)
132109
}
133110
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package app.aaps.implementation.scenes
2+
3+
import app.aaps.core.interfaces.notifications.NotificationManager
4+
import app.aaps.core.interfaces.resources.ResourceHelper
5+
import app.aaps.shared.tests.TestBase
6+
import com.google.common.truth.Truth.assertThat
7+
import org.junit.jupiter.api.Test
8+
import org.mockito.Mock
9+
import org.mockito.kotlin.whenever
10+
11+
/**
12+
* The two "is there a scene" questions and how they differ once a scene expires but its banner is
13+
* still up — the state that used to block every automation gated on "no scene running" (#5059).
14+
*/
15+
class SceneAutomationApiImplTest : TestBase() {
16+
17+
@Mock lateinit var sceneRepository: SceneRepository
18+
@Mock lateinit var sceneExecutor: SceneExecutor
19+
@Mock lateinit var activeSceneManager: ActiveSceneManager
20+
@Mock lateinit var sceneChainTargetResolver: SceneChainTargetResolver
21+
@Mock lateinit var notificationManager: NotificationManager
22+
@Mock lateinit var rh: ResourceHelper
23+
24+
private fun createSut() = SceneAutomationApiImpl(
25+
sceneRepository, sceneExecutor, activeSceneManager, sceneChainTargetResolver, notificationManager, rh
26+
)
27+
28+
private fun givenScene(active: Boolean, expired: Boolean) {
29+
whenever(activeSceneManager.isActive()).thenReturn(active)
30+
whenever(activeSceneManager.isExpired()).thenReturn(expired)
31+
}
32+
33+
@Test
34+
fun `a running scene is active and stoppable`() {
35+
givenScene(active = true, expired = false)
36+
37+
assertThat(createSut().isAnySceneActive()).isTrue()
38+
assertThat(createSut().hasSceneToStop()).isTrue()
39+
}
40+
41+
@Test
42+
fun `an expired scene is no longer active but is still stoppable`() {
43+
// Its effects were reverted by onExpiry; only the "ended" banner is left. Automation gated on
44+
// "no scene running" must be free to run, while the user (or the watch) can still dismiss it.
45+
givenScene(active = true, expired = true)
46+
47+
assertThat(createSut().isAnySceneActive()).isFalse()
48+
assertThat(createSut().hasSceneToStop()).isTrue()
49+
}
50+
51+
@Test
52+
fun `with no scene at all both answers are no`() {
53+
givenScene(active = false, expired = false)
54+
55+
assertThat(createSut().isAnySceneActive()).isFalse()
56+
assertThat(createSut().hasSceneToStop()).isFalse()
57+
}
58+
}

plugins/sync/src/main/kotlin/app/aaps/plugins/sync/wear/wearintegration/DataHandlerMobile.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,9 @@ class DataHandlerMobile @Inject constructor(
687687
private fun handleSceneStopPreCheck() {
688688
// Build confirm locally — no master round-trip needed before showing "End active scene".
689689
// The watch waits for RemoteDelivered (deferConfirm) while the stop relays to master.
690-
if (!scenes.isAnySceneActive()) return sendError(rh.gs(app.aaps.core.ui.R.string.scene_ended))
690+
// Wider than "a scene is running": ending an expired-but-undismissed scene from the watch is a
691+
// valid stop (stopActiveScene dismisses it), and it is the only remote way to clear that banner.
692+
if (!scenes.hasSceneToStop()) return sendError(rh.gs(app.aaps.core.ui.R.string.scene_ended))
691693
sendToWear(
692694
EventData.ConfirmAction(
693695
title = rh.gs(app.aaps.core.ui.R.string.scenes),
@@ -1101,7 +1103,9 @@ class DataHandlerMobile @Inject constructor(
11011103
sendUserActions()
11021104
// Scenes
11031105
sendScenes()
1104-
sendActiveSceneState(scenes.isAnySceneActive())
1106+
// Same condition as the live push in WearPlugin (scenes.activeFlow) — the tile is fed by both,
1107+
// so a resend must not disagree with the flow about whether the STOP button belongs there.
1108+
sendActiveSceneState(scenes.hasSceneToStop())
11051109
// GraphData
11061110
iobCobCalculator.ads.getBucketedDataTableCopy()?.let { bucketedData ->
11071111
// Hoist out of the per-bucket map: getGlucoseStatusData copies the bucketed table and runs a polynomial fit on every call.

0 commit comments

Comments
 (0)