Skip to content

Commit 8ca00ee

Browse files
Merge pull request #54 from a2i2/nick/interuptions-testing
Add in the interruptions handling logic [CON-2652]
2 parents bc451b2 + 9aaa3c9 commit 8ca00ee

File tree

16 files changed

+881
-74
lines changed

16 files changed

+881
-74
lines changed

EEFRT Demo Android/app/src/main/java/ai/a2i2/conductor/effrtdemoandroid/persistence/GameStorage.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import hu.autsoft.krate.booleanPref
66
import hu.autsoft.krate.default.withDefault
77
import hu.autsoft.krate.intPref
88
import hu.autsoft.krate.kotlinx.kotlinxPref
9+
import hu.autsoft.krate.longPref
910
import kotlinx.serialization.Serializable
1011

1112
@Serializable
@@ -14,11 +15,16 @@ data class GameCache(
1415
val trialNumber: Int = 0,
1516
var maxPressCount: Int = 0,
1617
val coinRunningTotal: Int = 0,
17-
val trialResults: Map<String, Int> = emptyMap<String, Int>(),
18+
val trialResults: Map<String, Int> = emptyMap(),
1819
val randTrialsIdx: List<Int>? = null,
1920
val trialSeqFilename: String? = null,
20-
var calibrationComplete: Boolean = false
21-
)
21+
var calibrationComplete: Boolean = false,
22+
var interruptionTimestamp: Long? = null
23+
) {
24+
fun isResumeTrialAvailable(): Boolean {
25+
return practiceComplete || trialNumber > 0
26+
}
27+
}
2228

2329
class GameStorage(context: Context) : SimpleKrate(context) {
2430
var cachedGameState: GameCache? by kotlinxPref<GameCache>("cachedGameState").withDefault(

EEFRT Demo Android/app/src/main/java/ai/a2i2/conductor/effrtdemoandroid/ui/EefrtScreen.kt

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,41 @@ import ai.a2i2.conductor.effrtdemoandroid.persistence.GameCache
44
import ai.a2i2.conductor.effrtdemoandroid.persistence.GameStorage
55
import ai.a2i2.conductor.effrtdemoandroid.persistence.PracticeTaskAttempt
66
import ai.a2i2.conductor.effrtdemoandroid.persistence.TaskAttempt
7+
import ai.a2i2.conductor.effrtdemoandroid.ui.data.CloseMessage
78
import ai.a2i2.conductor.effrtdemoandroid.ui.data.EefrtScreenViewModel
89
import android.annotation.SuppressLint
9-
import android.app.AlertDialog
1010
import android.content.Context
1111
import android.os.Handler
1212
import android.os.Looper
1313
import android.util.Log
1414
import android.view.ViewGroup
1515
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
16+
import android.view.Window
1617
import android.webkit.WebResourceRequest
1718
import android.webkit.WebResourceResponse
1819
import android.webkit.WebView
1920
import androidx.compose.foundation.layout.Box
2021
import androidx.compose.foundation.layout.WindowInsets
2122
import androidx.compose.foundation.layout.asPaddingValues
23+
import androidx.compose.foundation.layout.padding
2224
import androidx.compose.foundation.layout.statusBars
25+
import androidx.compose.foundation.layout.systemBars
2326
import androidx.compose.material3.AlertDialog
2427
import androidx.compose.material3.Text
2528
import androidx.compose.material3.TextButton
2629
import androidx.compose.runtime.Composable
30+
import androidx.compose.runtime.DisposableEffect
2731
import androidx.compose.runtime.MutableState
2832
import androidx.compose.runtime.mutableStateOf
2933
import androidx.compose.runtime.remember
3034
import androidx.compose.ui.Alignment
35+
import androidx.compose.ui.Modifier
3136
import androidx.compose.ui.platform.LocalContext
3237
import androidx.compose.ui.viewinterop.AndroidView
38+
import androidx.lifecycle.Lifecycle
39+
import androidx.lifecycle.LifecycleEventObserver
40+
import androidx.lifecycle.compose.LifecycleEventEffect
41+
import androidx.lifecycle.compose.LocalLifecycleOwner
3342
import androidx.webkit.WebViewAssetLoader
3443
import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
3544
import androidx.webkit.WebViewAssetLoader.DEFAULT_DOMAIN
@@ -39,6 +48,7 @@ import kotlinx.serialization.encodeToString
3948
import kotlinx.serialization.json.Json
4049
import org.json.JSONException
4150
import org.json.JSONObject
51+
import java.time.Instant
4252
import java.util.Date
4353

4454
@SuppressLint("SetJavaScriptEnabled")
@@ -50,13 +60,31 @@ fun EefrtScreen(
5060
val exitRequested = remember { mutableStateOf(false) }
5161
val webView = remember { mutableStateOf<WebView?>(null) }
5262
val context = LocalContext.current
63+
val lifecycleOwner = LocalLifecycleOwner.current
5364

5465
val insets = WindowInsets.statusBars.asPaddingValues()
5566
val topPaddingDp = insets.calculateTopPadding().value.toInt()
5667
// On first composition the value is 0, so we need to wait for the second composition to get
5768
// the actual value before we continue to render the WebView.
5869
if (topPaddingDp <= 0) return
5970

71+
DisposableEffect(lifecycleOwner) {
72+
val observer = LifecycleEventObserver { _, event ->
73+
when (event) {
74+
Lifecycle.Event.ON_RESUME -> onTaskResume(viewModel, context, webView.value)
75+
Lifecycle.Event.ON_PAUSE -> onTaskPause(viewModel)
76+
else -> Unit
77+
}
78+
}
79+
80+
lifecycleOwner.lifecycle.addObserver(observer)
81+
82+
onDispose {
83+
lifecycleOwner.lifecycle.removeObserver(observer)
84+
viewModel.interruptionTimestamp.value = null
85+
}
86+
}
87+
6088
Box(contentAlignment = Alignment.Center) {
6189
AndroidView(
6290
factory = {
@@ -92,13 +120,16 @@ fun EefrtScreen(
92120

93121
// inject the stored calibrationComplete and calibratedMaxPressCount values into the cache
94122
val gameStorage = GameStorage(context)
95-
var cache = gameStorage.cachedGameState ?: GameCache()
123+
val cache = gameStorage.cachedGameState ?: GameCache()
96124
if (gameStorage.calibrationComplete == true && gameStorage.calibratedMaxPressCount != null) {
97125
cache.calibrationComplete = true
98126
cache.maxPressCount = gameStorage.calibratedMaxPressCount!!
99127
}
100128

101-
val cacheJson = Json.encodeToString(cache)
129+
val json = Json {
130+
encodeDefaults = true
131+
}
132+
val cacheJson = json.encodeToString(cache)
102133
val jsString = "window.setupGameWithCache(${cacheJson});"
103134
evaluateJavascript(jsString, null)
104135
}
@@ -129,22 +160,22 @@ fun EefrtScreen(
129160
"You have not completed enough rounds to earn the bonus payment and will lose your progress."
130161

131162
AlertDialog(
132-
onDismissRequest = { viewModel.showExitDialog.value = false },
163+
onDismissRequest = { viewModel.dismissCloseDialog() },
133164
title = { Text("Quit task?") },
134165
text = { Text(message) },
135166
confirmButton = {
136167
TextButton(
137168
onClick = {
138-
viewModel.showExitDialog.value = false
139-
169+
viewModel.onConfirmCloseDialog(TAG)
170+
exitRequested.value = true
140171
dismiss(onBack)
141172
}
142173
) {
143174
Text("Yes, quit task")
144175
}
145176
},
146177
dismissButton = {
147-
TextButton(onClick = { viewModel.showExitDialog.value = false }) {
178+
TextButton(onClick = { viewModel.dismissCloseDialog() }) {
148179
Text("Cancel")
149180
}
150181
}
@@ -155,12 +186,43 @@ fun EefrtScreen(
155186

156187
private const val TAG = "EefrtScreen"
157188

189+
private fun onTaskPause(viewModel: EefrtScreenViewModel) {
190+
// ensure this value is only updated once, while we are actively using the app
191+
if (viewModel.interruptionTimestamp.value == null) {
192+
viewModel.interruptionTimestamp.value = Instant.now().toEpochMilli()
193+
}
194+
}
195+
196+
private fun onTaskResume(viewModel: EefrtScreenViewModel, context: Context, webView: WebView?) {
197+
val cache = viewModel.getCurrentGameState(context)
198+
?.copy() // ensure we get a copy of the game state so the changes we make to the timestamp arent persisted
199+
val interruptionTimestamp = viewModel.interruptionTimestamp.value
200+
201+
if (cache == null || interruptionTimestamp == null || webView == null) {
202+
return
203+
}
204+
// append the interruption timestamp to the cache
205+
cache.interruptionTimestamp = interruptionTimestamp
206+
207+
// encode the cache and pass it along to the game
208+
val json = Json {
209+
encodeDefaults = true
210+
}
211+
val cacheJson = json.encodeToString(cache)
212+
val jsString = "window.setupGameWithCache(${cacheJson});"
213+
webView.evaluateJavascript(jsString, null)
214+
215+
// reset the interruptionTimestamp value
216+
cache.interruptionTimestamp = null
217+
viewModel.interruptionTimestamp.value = null
218+
}
219+
158220
private fun handleMessage(
159221
message: String,
160222
onBack: () -> Unit,
161223
exitRequested: MutableState<Boolean>,
162224
viewModel: EefrtScreenViewModel,
163-
context: Context
225+
context: Context,
164226
) {
165227
try {
166228
val obj = JSONObject(message)
@@ -177,7 +239,8 @@ private fun handleMessage(
177239
If they are still in the practice trials, just exit the task.
178240
We also want to not show the exit dialog if they are shown the Times Up message.
179241
180-
Messages from this key will contain an Boolean value which determines if we show the exit dialog or not
242+
We may also be requested to exit the task if a significant interruption has occurred and will need to determine if the
243+
task attempt count should be incremented or not as well.
181244
*/
182245

183246
if (obj.isNull("message")) {
@@ -190,9 +253,29 @@ private fun handleMessage(
190253
return
191254
}
192255

193-
val shouldShowDialog = obj.getBoolean("message")
194-
if (shouldShowDialog) {
195-
showDialogMessage(viewModel)
256+
// decode the message from the JS side
257+
val body = obj.getString("message")
258+
val gson = Gson()
259+
val closeMessage = gson.fromJson(body, CloseMessage::class.java)
260+
261+
// ensure its only incremented if the game is closed, the user has the option to cancel closing the task via the dialog
262+
if (!closeMessage.shouldShowExitDialog && closeMessage.incrementAttemptCount) {
263+
Log.d(
264+
TAG,
265+
"Incremented attempt count"
266+
)
267+
}
268+
269+
// ensure the game data is reset if we've actually closed the task, similar scenario to the shouldShowExitDialog
270+
if (!closeMessage.shouldShowExitDialog && closeMessage.taskRequiresRestart) {
271+
Log.d(
272+
TAG,
273+
"Task will be restarted on next load"
274+
)
275+
}
276+
277+
if (closeMessage.shouldShowExitDialog) {
278+
viewModel.showCloseDialog(closeMessage)
196279
} else {
197280
exitRequested.value = true
198281
dismiss(onBack)
@@ -238,6 +321,7 @@ private fun handleMessage(
238321

239322
"gameComplete" -> {
240323
viewModel.clearEEFRTData(context)
324+
exitRequested.value = true
241325
dismiss(onBack)
242326
}
243327

@@ -254,7 +338,3 @@ private fun handleMessage(
254338
private fun dismiss(onBack: () -> Unit) {
255339
Handler(Looper.getMainLooper()).post { onBack() }
256340
}
257-
258-
private fun showDialogMessage(eefrtViewModel: EefrtScreenViewModel) {
259-
eefrtViewModel.showExitDialog.value = true
260-
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package ai.a2i2.conductor.effrtdemoandroid.ui.data
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class CloseMessage(
7+
val shouldShowExitDialog: Boolean,
8+
val incrementAttemptCount: Boolean,
9+
val taskRequiresRestart: Boolean,
10+
)

EEFRT Demo Android/app/src/main/java/ai/a2i2/conductor/effrtdemoandroid/ui/data/EefrtScreenViewModel.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import ai.a2i2.conductor.effrtdemoandroid.persistence.GameCache
55
import ai.a2i2.conductor.effrtdemoandroid.persistence.GameStorage
66
import ai.a2i2.conductor.effrtdemoandroid.persistence.PracticeTaskAttempt
77
import ai.a2i2.conductor.effrtdemoandroid.persistence.TaskAttempt
8+
import ai.a2i2.conductor.effrtdemoandroid.ui.EefrtScreen
89
import ai.a2i2.conductor.effrtdemoandroid.util.GameConfigUtils
910
import android.content.Context
11+
import android.util.Log
1012
import androidx.compose.runtime.MutableState
1113
import androidx.compose.runtime.State
1214
import androidx.compose.runtime.mutableStateOf
1315
import androidx.compose.ui.platform.LocalContext
1416
import androidx.lifecycle.ViewModel
1517
import androidx.lifecycle.viewModelScope
1618
import kotlinx.coroutines.launch
19+
import java.time.Instant
1720

1821
class EefrtScreenViewModel(
1922
private val appDatabase: AppDatabase,
@@ -22,13 +25,16 @@ class EefrtScreenViewModel(
2225

2326
private val _practiceTrialData = mutableStateOf<List<PracticeTaskAttempt>>(emptyList())
2427
private val _actualTrialData = mutableStateOf<List<TaskAttempt>>(emptyList())
28+
2529
val resumeTrialAvailable: MutableState<Boolean>
2630
var showExitDialog: MutableState<Boolean> = mutableStateOf(false)
31+
var interruptionTimestamp = mutableStateOf<Long?>(null)
32+
var closeMessage = mutableStateOf<CloseMessage?>(null)
2733

2834
init {
2935
refreshData()
3036
val currentGameState = GameStorage(context).cachedGameState
31-
resumeTrialAvailable = mutableStateOf((currentGameState?.trialNumber ?: 0) > 0)
37+
resumeTrialAvailable = mutableStateOf(currentGameState?.isResumeTrialAvailable() ?: false)
3238
}
3339

3440
private fun refreshData() {
@@ -85,7 +91,7 @@ class EefrtScreenViewModel(
8591

8692
fun setCurrentGameState(context: Context, newGameState: GameCache?) {
8793
GameStorage(context).cachedGameState = newGameState
88-
resumeTrialAvailable.value = (newGameState?.trialNumber ?: 0) > 0
94+
resumeTrialAvailable.value = newGameState?.isResumeTrialAvailable() ?: false
8995
}
9096

9197
fun getCurrentGameState(context: Context): GameCache? {
@@ -99,6 +105,7 @@ class EefrtScreenViewModel(
99105
// we don't care for the business logic in this app, remember to add it into the main vibe up 2 apps
100106
fun clearEEFRTData(context: Context) {
101107
GameStorage(context).cachedGameState = null
108+
resumeTrialAvailable.value = false
102109
}
103110

104111
fun markCalibrationAsComplete(context: Context) {
@@ -108,4 +115,36 @@ class EefrtScreenViewModel(
108115
fun setCalibratedMaxPressCount(context: Context, pressCount: Int) {
109116
GameStorage(context).calibratedMaxPressCount = pressCount
110117
}
118+
119+
fun showCloseDialog(closeMessage: CloseMessage) {
120+
this.closeMessage.value = closeMessage
121+
showExitDialog.value = true
122+
}
123+
124+
fun dismissCloseDialog() {
125+
closeMessage.value = null
126+
showExitDialog.value = false
127+
}
128+
129+
fun onConfirmCloseDialog(loggingTag: String) {
130+
// No business logic for this app but a placeholder for the main apps
131+
closeMessage.value?.let {
132+
if (it.incrementAttemptCount) {
133+
Log.d(
134+
loggingTag,
135+
"Incremented attempt count"
136+
)
137+
}
138+
139+
if (it.taskRequiresRestart) {
140+
Log.d(
141+
loggingTag,
142+
"Task will be restarted on next load"
143+
)
144+
}
145+
}
146+
147+
// dismiss the dialog
148+
dismissCloseDialog()
149+
}
111150
}

EEFRT Demo iOS/EEFRT Demo/ContentView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct ContentView: View {
2323
Button("Cancel", role: .cancel) {}
2424
}
2525

26-
if viewModel.gameCache?.trialNumber ?? 0 > 0, let cache = viewModel.gameCache {
26+
if let cache = viewModel.gameCache, cache.isResumeTrialAvailable() {
2727
NavigationLink(destination: EEFRTView(gameCache: cache)
2828
.ignoresSafeArea()
2929
.navigationBarBackButtonHidden()) {

0 commit comments

Comments
 (0)