package expo.modules.updates.statemachine import expo.modules.manifests.core.toMap import expo.modules.updates.EnabledUpdatesController import expo.modules.updates.events.IUpdatesEventManager import expo.modules.updates.logging.UpdatesLogger import expo.modules.updates.procedures.StateMachineProcedure import expo.modules.updates.procedures.StateMachineSerialExecutorQueue import expo.modules.updatesinterface.UpdatesControllerRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import java.util.Date /** * The Updates state machine class. There should be only one instance of this class * in a production app, instantiated as a property of UpdatesController. */ class UpdatesStateMachine( private val logger: UpdatesLogger, private val eventManager: IUpdatesEventManager, private val validUpdatesStateValues: Set, scope: CoroutineScope = CoroutineScope(Dispatchers.IO) ) { private val serialExecutorQueue = StateMachineSerialExecutorQueue( logger, object : StateMachineProcedure.StateMachineProcedureContext { override fun processStateEvent(event: UpdatesStateEvent) { this@UpdatesStateMachine.processEvent(event) } @Deprecated("Avoid needing to access current state to know how to transition to next state") override fun getCurrentState(): UpdatesStateValue { return state } override fun resetStateAfterRestart() { resetAndIncrementRestartCount() } }, scope ) /** * Queue a StateMachineProcedure procedure for serial execution. */ fun queueExecution(stateMachineProcedure: StateMachineProcedure) { serialExecutorQueue.queueExecution(stateMachineProcedure) } /** * The current state */ private var state: UpdatesStateValue = UpdatesStateValue.Idle /** * The context */ var context: UpdatesStateContext = UpdatesStateContext() private set /** * Reset the machine to its starting state. Should only be called after the app restarts (reloadAsync()). */ private fun resetAndIncrementRestartCount() { state = UpdatesStateValue.Idle context = context.resetCopyWithIncrementedRestartCountAndSequenceNumber() logger.info("Updates state change: reset, context = ${context.json}") sendContextToJS() } private fun toMap(event: UpdatesStateEvent): Map { return when (event) { is UpdatesStateEvent.DownloadCompleteWithUpdate -> mapOf("type" to "downloadCompleteWithUpdate", "manifest" to event.manifest.toMap()) is UpdatesStateEvent.CheckCompleteWithUpdate -> mapOf("type" to "checkCompleteWithUpdate", "manifest" to event.manifest.toMap()) is UpdatesStateEvent.CheckCompleteWithRollback -> mapOf("type" to "checkCompleteWithRollback") is UpdatesStateEvent.CheckError -> mapOf("type" to "checkError", "errorMessage" to event.error.message) is UpdatesStateEvent.DownloadError -> mapOf("type" to "downloadError", "errorMessage" to event.error.message) is UpdatesStateEvent.DownloadProgress -> mapOf("type" to "downloadProgress", "progress" to event.progress) is UpdatesStateEvent.Check -> mapOf("type" to event.type.type) is UpdatesStateEvent.CheckCompleteUnavailable -> mapOf("type" to event.type.type) is UpdatesStateEvent.Download -> mapOf("type" to event.type.type) is UpdatesStateEvent.DownloadComplete -> mapOf("type" to event.type.type) is UpdatesStateEvent.DownloadCompleteWithRollback -> mapOf("type" to event.type.type) is UpdatesStateEvent.Restart -> mapOf("type" to event.type.type) is UpdatesStateEvent.StartStartup -> mapOf("type" to event.type.type) is UpdatesStateEvent.EndStartup -> mapOf("type" to event.type.type) } } /** * Transition the state machine forward to a new state. */ private fun processEvent(event: UpdatesStateEvent) { if (transition(event)) { context = reduceContext(context, event) if (event !is UpdatesStateEvent.DownloadProgress) { logger.info("Updates state change: ${event.type}, context = ${context.json}") } UpdatesControllerRegistry.controller?.get()?.let { if (it is EnabledUpdatesController) { // Notify the controller state change listener it.stateChangeListenerMap.keys.forEach { key -> it.stateChangeListenerMap[key]?.updatesStateDidChange(toMap(event)) } } } sendContextToJS() } } /** Make sure the state transition is allowed, and then update the state. */ private fun transition(event: UpdatesStateEvent): Boolean { val allowedEvents: Set = updatesStateAllowedEvents[state] ?: setOf() if (!allowedEvents.contains(event.type)) { assert(false) { "UpdatesState: invalid transition requested: state = $state, event = ${event.type}" } return false } val newStateValue = updatesStateTransitions[event.type] ?: UpdatesStateValue.Idle if (!validUpdatesStateValues.contains(newStateValue)) { assert(false) { "UpdatesState: invalid transition requested: state = $state, event = ${event.type}" } return false } state = newStateValue return true } fun sendContextToJS() { eventManager.sendStateMachineContextEvent(context) } companion object { /** For a particular machine state, only certain events may be processed. */ val updatesStateAllowedEvents: Map> = mapOf( UpdatesStateValue.Idle to setOf(UpdatesStateEventType.StartStartup, UpdatesStateEventType.EndStartup, UpdatesStateEventType.Check, UpdatesStateEventType.Download, UpdatesStateEventType.Restart), UpdatesStateValue.Checking to setOf(UpdatesStateEventType.CheckCompleteAvailable, UpdatesStateEventType.CheckCompleteUnavailable, UpdatesStateEventType.CheckError), UpdatesStateValue.Downloading to setOf(UpdatesStateEventType.DownloadComplete, UpdatesStateEventType.DownloadError, UpdatesStateEventType.DownloadProgress), UpdatesStateValue.Restarting to setOf() ) /** For this state machine, each event has only one destination state that the machine will transition to. */ val updatesStateTransitions: Map = mapOf( UpdatesStateEventType.StartStartup to UpdatesStateValue.Idle, UpdatesStateEventType.EndStartup to UpdatesStateValue.Idle, UpdatesStateEventType.Check to UpdatesStateValue.Checking, UpdatesStateEventType.CheckCompleteAvailable to UpdatesStateValue.Idle, UpdatesStateEventType.CheckCompleteUnavailable to UpdatesStateValue.Idle, UpdatesStateEventType.CheckError to UpdatesStateValue.Idle, UpdatesStateEventType.Download to UpdatesStateValue.Downloading, UpdatesStateEventType.DownloadProgress to UpdatesStateValue.Downloading, UpdatesStateEventType.DownloadComplete to UpdatesStateValue.Idle, UpdatesStateEventType.DownloadError to UpdatesStateValue.Idle, UpdatesStateEventType.Restart to UpdatesStateValue.Restarting ) /** * Given an allowed event and a context, return a new context with the changes * made by processing the event. */ private fun reduceContext(context: UpdatesStateContext, event: UpdatesStateEvent): UpdatesStateContext { return when (event) { is UpdatesStateEvent.StartStartup -> context.copyAndIncrementSequenceNumber( isStartupProcedureRunning = true ) is UpdatesStateEvent.EndStartup -> context.copyAndIncrementSequenceNumber( isStartupProcedureRunning = false ) is UpdatesStateEvent.Check -> context.copyAndIncrementSequenceNumber( isChecking = true ) is UpdatesStateEvent.CheckCompleteUnavailable -> context.copyAndIncrementSequenceNumber( isChecking = false, checkError = null, latestManifest = null, rollback = null, isUpdateAvailable = false, lastCheckForUpdateTime = Date() ) is UpdatesStateEvent.CheckCompleteWithRollback -> context.copyAndIncrementSequenceNumber( isChecking = false, checkError = null, latestManifest = null, rollback = UpdatesStateContextRollback(event.commitTime), isUpdateAvailable = true, lastCheckForUpdateTime = Date() ) is UpdatesStateEvent.CheckCompleteWithUpdate -> context.copyAndIncrementSequenceNumber( isChecking = false, checkError = null, latestManifest = event.manifest, rollback = null, isUpdateAvailable = true, lastCheckForUpdateTime = Date() ) is UpdatesStateEvent.CheckError -> context.copyAndIncrementSequenceNumber( isChecking = false, checkError = event.error, lastCheckForUpdateTime = Date() ) is UpdatesStateEvent.Download -> context.copyAndIncrementSequenceNumber( downloadProgress = 0.0, isDownloading = true, downloadStartTime = Date(), downloadFinishTime = null ) is UpdatesStateEvent.DownloadProgress -> context.copyAndIncrementSequenceNumber( downloadProgress = event.progress ) is UpdatesStateEvent.DownloadComplete -> context.copyAndIncrementSequenceNumber( isDownloading = false, downloadError = null, isUpdatePending = true, downloadProgress = 1.0, downloadStartTime = null, downloadFinishTime = null ) is UpdatesStateEvent.DownloadCompleteWithRollback -> context.copyAndIncrementSequenceNumber( isDownloading = false, downloadError = null, isUpdatePending = true, downloadStartTime = null, downloadFinishTime = null ) is UpdatesStateEvent.DownloadCompleteWithUpdate -> context.copyAndIncrementSequenceNumber( isDownloading = false, downloadError = null, latestManifest = event.manifest, downloadedManifest = event.manifest, rollback = null, isUpdatePending = true, isUpdateAvailable = true, downloadFinishTime = Date() ) is UpdatesStateEvent.DownloadError -> context.copyAndIncrementSequenceNumber( isDownloading = false, downloadError = event.error, downloadStartTime = null, downloadFinishTime = null ) is UpdatesStateEvent.Restart -> context.copyAndIncrementSequenceNumber( isRestarting = true ) } } } }