@@ -4,32 +4,41 @@ import ai.a2i2.conductor.effrtdemoandroid.persistence.GameCache
44import ai.a2i2.conductor.effrtdemoandroid.persistence.GameStorage
55import ai.a2i2.conductor.effrtdemoandroid.persistence.PracticeTaskAttempt
66import ai.a2i2.conductor.effrtdemoandroid.persistence.TaskAttempt
7+ import ai.a2i2.conductor.effrtdemoandroid.ui.data.CloseMessage
78import ai.a2i2.conductor.effrtdemoandroid.ui.data.EefrtScreenViewModel
89import android.annotation.SuppressLint
9- import android.app.AlertDialog
1010import android.content.Context
1111import android.os.Handler
1212import android.os.Looper
1313import android.util.Log
1414import android.view.ViewGroup
1515import android.view.ViewGroup.LayoutParams.MATCH_PARENT
16+ import android.view.Window
1617import android.webkit.WebResourceRequest
1718import android.webkit.WebResourceResponse
1819import android.webkit.WebView
1920import androidx.compose.foundation.layout.Box
2021import androidx.compose.foundation.layout.WindowInsets
2122import androidx.compose.foundation.layout.asPaddingValues
23+ import androidx.compose.foundation.layout.padding
2224import androidx.compose.foundation.layout.statusBars
25+ import androidx.compose.foundation.layout.systemBars
2326import androidx.compose.material3.AlertDialog
2427import androidx.compose.material3.Text
2528import androidx.compose.material3.TextButton
2629import androidx.compose.runtime.Composable
30+ import androidx.compose.runtime.DisposableEffect
2731import androidx.compose.runtime.MutableState
2832import androidx.compose.runtime.mutableStateOf
2933import androidx.compose.runtime.remember
3034import androidx.compose.ui.Alignment
35+ import androidx.compose.ui.Modifier
3136import androidx.compose.ui.platform.LocalContext
3237import 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
3342import androidx.webkit.WebViewAssetLoader
3443import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
3544import androidx.webkit.WebViewAssetLoader.DEFAULT_DOMAIN
@@ -39,6 +48,7 @@ import kotlinx.serialization.encodeToString
3948import kotlinx.serialization.json.Json
4049import org.json.JSONException
4150import org.json.JSONObject
51+ import java.time.Instant
4252import 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
156187private 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+
158220private 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(
254338private fun dismiss (onBack : () -> Unit ) {
255339 Handler (Looper .getMainLooper()).post { onBack() }
256340}
257-
258- private fun showDialogMessage (eefrtViewModel : EefrtScreenViewModel ) {
259- eefrtViewModel.showExitDialog.value = true
260- }
0 commit comments