package expo.modules.updates import android.app.Activity import android.content.Context import android.net.Uri import android.os.Bundle import com.facebook.react.ReactHost import com.facebook.react.bridge.ReactContext import com.facebook.react.devsupport.interfaces.DevSupportManager import expo.modules.easclient.EASClientID import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.exception.toCodedException import expo.modules.updates.db.BuildData import expo.modules.updates.db.DatabaseHolder import expo.modules.updates.db.UpdatesDatabase import expo.modules.updates.db.entity.UpdateEntity import expo.modules.updates.events.IUpdatesEventManager import expo.modules.updates.events.UpdatesEventManager import expo.modules.updates.launcher.Launcher.LauncherCallback import expo.modules.updates.loader.FileDownloader import expo.modules.updates.logging.UpdatesErrorCode import expo.modules.updates.logging.UpdatesLogReader import expo.modules.updates.logging.UpdatesLogger import expo.modules.updates.manifest.EmbeddedManifestUtils import expo.modules.updates.manifest.ManifestMetadata import expo.modules.updates.procedures.CheckForUpdateProcedure import expo.modules.updates.procedures.FetchUpdateProcedure import expo.modules.updates.procedures.RelaunchProcedure import expo.modules.updates.procedures.StartupProcedure import expo.modules.updates.reloadscreen.ReloadScreenManager import expo.modules.updates.selectionpolicy.SelectionPolicy import expo.modules.updates.selectionpolicy.SelectionPolicyFactory import expo.modules.updates.statemachine.UpdatesStateMachine import expo.modules.updates.statemachine.UpdatesStateValue import expo.modules.updatesinterface.UpdatesInterface import expo.modules.updatesinterface.UpdatesStateChangeListener import expo.modules.updatesinterface.UpdatesStateChangeSubscription import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File import java.lang.ref.WeakReference import java.util.UUID import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.time.DurationUnit import kotlin.time.toDuration /** * Updates controller for applications that have updates enabled and properly-configured. */ class EnabledUpdatesController( private val context: Context, private var updatesConfiguration: UpdatesConfiguration, override val updatesDirectory: File ) : IUpdatesController, UpdatesInterface { /** Keep the activity for [RelaunchProcedure] to relaunch the app. */ private var weakActivity: WeakReference? = null private val logger = UpdatesLogger(context.filesDir) override val eventManager: IUpdatesEventManager = UpdatesEventManager(logger) private val selectionPolicy: SelectionPolicy get() = SelectionPolicyFactory.createFilterAwarePolicy(updatesConfiguration.getRuntimeVersion(), updatesConfiguration) private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val stateMachine = UpdatesStateMachine(logger, eventManager, UpdatesStateValue.entries.toSet(), controllerScope) private val fileDownloader: FileDownloader get() = FileDownloader( context.filesDir, EASClientID(context).uuid.toString(), updatesConfiguration, logger, databaseHolder.database ) private val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context, Dispatchers.IO)) private val startupFinishedDeferred = CompletableDeferred() private val startupFinishedMutex = Mutex() override val reloadScreenManager = ReloadScreenManager() override var reactHost: WeakReference = WeakReference(null) internal val stateChangeListenerMap: MutableMap = mutableMapOf() private fun purgeUpdatesLogsOlderThanOneDay() { UpdatesLogReader(context.filesDir).purgeLogEntries { if (it != null) { logger.error("UpdatesLogReader: error in purgeLogEntries", it, UpdatesErrorCode.Unknown) } } } private var isStarted = false private var isStartupFinished = false private var startupStartTimeMillis: Long? = null private var startupEndTimeMillis: Long? = null @Synchronized private fun onStartupProcedureFinished() { controllerScope.launch { startupFinishedMutex.withLock { if (!startupFinishedDeferred.isCompleted) { startupFinishedDeferred.complete(Unit) } } } isStartupFinished = true startupEndTimeMillis = System.currentTimeMillis() } private val startupProcedure = StartupProcedure( context, updatesConfiguration, databaseHolder, updatesDirectory, fileDownloader, selectionPolicy, logger, object : StartupProcedure.StartupProcedureCallback { override fun onFinished() { onStartupProcedureFinished() } override fun onRequestRelaunch(shouldRunReaper: Boolean, callback: LauncherCallback) { relaunchReactApplication(shouldRunReaper, callback) } }, controllerScope ) private val launchedUpdate get() = startupProcedure.launchedUpdate private val launchDuration get() = startupStartTimeMillis?.let { start -> startupEndTimeMillis?.let { end -> (end - start).toDuration(DurationUnit.MILLISECONDS) } } private val isUsingEmbeddedAssets get() = startupProcedure.isUsingEmbeddedAssets private val localAssetFiles get() = startupProcedure.localAssetFiles override val launchAssetFile: String? get() { runBlocking { startupFinishedDeferred.await() } return startupProcedure.launchAssetFile } override val bundleAssetName: String? get() = startupProcedure.bundleAssetName override fun onEventListenerStartObserving() { stateMachine.sendContextToJS() } override fun onDidCreateDevSupportManager(devSupportManager: DevSupportManager) { startupProcedure.onDidCreateDevSupportManager(devSupportManager) } override fun onDidCreateReactInstance(reactContext: ReactContext) { weakActivity = WeakReference(reactContext.currentActivity) } override fun onReactInstanceException(exception: Exception) { startupProcedure.onReactInstanceException(exception) } override val isActiveController = true @Synchronized override fun start() { if (isStarted) { return } isStarted = true startupStartTimeMillis = System.currentTimeMillis() purgeUpdatesLogsOlderThanOneDay() if (!updatesConfiguration.hasUpdatesOverride) { BuildData.ensureBuildDataIsConsistent(updatesConfiguration, databaseHolder.database) } stateMachine.queueExecution(startupProcedure) } private fun relaunchReactApplication(shouldRunReaper: Boolean, callback: LauncherCallback) { val procedure = RelaunchProcedure( context, weakActivity, updatesConfiguration, logger, databaseHolder, updatesDirectory, fileDownloader, selectionPolicy, getCurrentLauncher = { startupProcedure.launcher!! }, setCurrentLauncher = { currentLauncher -> startupProcedure.setLauncher(currentLauncher) }, shouldRunReaper = shouldRunReaper, reloadScreenManager = reloadScreenManager, callback, controllerScope ) stateMachine.queueExecution(procedure) } private fun getEmbeddedUpdate(): UpdateEntity? { return EmbeddedManifestUtils.getEmbeddedUpdate(context, updatesConfiguration)?.updateEntity } override fun getConstantsForModule(): IUpdatesController.UpdatesModuleConstants { return IUpdatesController.UpdatesModuleConstants( launchedUpdate = launchedUpdate, launchDuration = launchDuration, embeddedUpdate = getEmbeddedUpdate(), emergencyLaunchException = startupProcedure.emergencyLaunchException, isEnabled = true, isUsingEmbeddedAssets = isUsingEmbeddedAssets, runtimeVersion = updatesConfiguration.runtimeVersionRaw, checkOnLaunch = updatesConfiguration.checkOnLaunch, requestHeaders = updatesConfiguration.requestHeaders, localAssetFiles = localAssetFiles, shouldDeferToNativeForAPIMethodAvailabilityInDevelopment = false, initialContext = stateMachine.context ) } override suspend fun relaunchReactApplicationForModule() = suspendCancellableCoroutine { continuation -> val canRelaunch = launchedUpdate != null if (!canRelaunch) { continuation.resumeWithException(object : CodedException("ERR_UPDATES_RELOAD", "Cannot relaunch without a launched update.", null) {}) } else { relaunchReactApplication( shouldRunReaper = true, object : LauncherCallback { override fun onFailure(e: Exception) { continuation.resumeWithException(e.toCodedException()) } override fun onSuccess() { continuation.resume(Unit) } } ) } } override suspend fun checkForUpdate() = suspendCancellableCoroutine { continuation -> val procedure = CheckForUpdateProcedure(context, updatesConfiguration, databaseHolder, logger, fileDownloader, selectionPolicy, launchedUpdate) { continuation.resume(it) } stateMachine.queueExecution(procedure) } override suspend fun fetchUpdate() = suspendCancellableCoroutine { continuation -> val procedure = FetchUpdateProcedure(context, updatesConfiguration, logger, databaseHolder, updatesDirectory, fileDownloader, selectionPolicy, launchedUpdate, controllerScope) { continuation.resume(it) } stateMachine.queueExecution(procedure) } override suspend fun getExtraParams() = suspendCancellableCoroutine { continuation -> controllerScope.launch { try { val result = ManifestMetadata.getExtraParams( databaseHolder.database, updatesConfiguration ) val resultMap = when (result) { null -> Bundle() else -> { Bundle().apply { result.forEach { putString(it.key, it.value) } } } } continuation.resume(resultMap) } catch (e: CancellationException) { throw e } catch (e: Exception) { continuation.resumeWithException(e.toCodedException()) } } } override suspend fun setExtraParam(key: String, value: String?) = suspendCancellableCoroutine { continuation -> controllerScope.launch { try { ManifestMetadata.setExtraParam( databaseHolder.database, updatesConfiguration, key, value ) continuation.resume(Unit) } catch (e: CancellationException) { throw e } catch (e: Exception) { continuation.resumeWithException(e.toCodedException()) } } } override fun shutdown() { controllerScope.cancel() } override fun setUpdateURLAndRequestHeadersOverride(configOverride: UpdatesConfigurationOverride?) { if (!updatesConfiguration.disableAntiBrickingMeasures) { throw CodedException("ERR_UPDATES_RUNTIME_OVERRIDE", "Must set disableAntiBrickingMeasures configuration to use updates overriding", null) } UpdatesConfigurationOverride.save(context, configOverride) updatesConfiguration = UpdatesConfiguration.create(context, updatesConfiguration, configOverride) } override fun setUpdateRequestHeadersOverride(requestHeaders: Map?) { val isValidRequestHeaders = UpdatesConfiguration.isValidRequestHeadersOverride( updatesConfiguration.originalEmbeddedRequestHeaders, requestHeaders ) if (!isValidRequestHeaders) { throw CodedException( "ERR_UPDATES_RUNTIME_OVERRIDE", "Invalid update requestHeaders override: $requestHeaders. " + "Override keys must be declared in `updates.requestHeaders` in your app config " + "at build time. Add the key to `updates.requestHeaders` and rebuild the app.", null ) } val configOverride = UpdatesConfigurationOverride.saveRequestHeaders(context, requestHeaders) updatesConfiguration = UpdatesConfiguration.create(context, updatesConfiguration, configOverride) } // UpdatesInterface implementations override val runtimeVersion: String? get() = updatesConfiguration.runtimeVersionRaw override val updateUrl: Uri? get() = updatesConfiguration.updateUrl override val requestHeaders: Map? get() = updatesConfiguration.requestHeaders override val launchedUpdateId: UUID? get() = startupProcedure.launchedUpdate?.id override val embeddedUpdateId: UUID? get() = getEmbeddedUpdate()?.id override val launchAssetPath: String? get() = startupProcedure.launchAssetFile override fun subscribeToUpdatesStateChanges(listener: UpdatesStateChangeListener): UpdatesStateChangeSubscription { val subscriptionId = UUID.randomUUID().toString() stateChangeListenerMap[subscriptionId] = listener return EnabledUpdatesStateChangeSubscription(subscriptionId = subscriptionId) } fun unsubscribeFromUpdatesStateChanges(subscriptionId: String) { if (stateChangeListenerMap.containsKey(subscriptionId)) { stateChangeListenerMap.remove(subscriptionId) } } internal fun getNativeInterfaceContext(): expo.modules.updatesinterface.UpdatesNativeInterfaceStateContext { return stateMachine.context.nativeInterfaceContext } override val isEnabled: Boolean = true companion object { private val TAG = EnabledUpdatesController::class.java.simpleName } }