package app.pulse.updates.launcher import android.content.Context import app.pulse.updates.pulseLog import app.pulse.updates.pulseLogWarn import app.pulse.updates.pulseLogError import app.pulse.updates.database.PulseAsset import app.pulse.updates.database.PulseDatabase import app.pulse.updates.database.PulseUpdate import app.pulse.updates.database.PulseUpdateStatus import java.io.File import java.io.FileOutputStream import java.io.InputStream import java.net.URL import java.security.MessageDigest import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** * PulseUpdates App Launcher * Based on expo-updates AppLauncherWithDatabase * Handles launching updates with 3-step asset fallback support */ class PulseAppLauncher( private val context: Context, private val database: PulseDatabase, private val directory: File, private val selectionPolicy: PulseSelectionPolicy, private val runtimeVersion: String ) { companion object { private const val TAG = "PulseAppLauncher" } // Public properties var launchedUpdate: PulseUpdate? = null private set var launchAssetFile: File? = null private set var assetFilesMap: Map = emptyMap() private set val isUsingEmbeddedAssets: Boolean get() = launchedUpdate?.isEmbedded == true // Private state private var embeddedManifest: EmbeddedManifest? = null /** * Launch the best available update */ fun launch(callback: (success: Boolean, error: Exception?) -> Unit) { try { val updates = database.launchableUpdates(runtimeVersion) val selectedUpdate = selectionPolicy.selectUpdateToLaunch(updates) if (selectedUpdate == null) { callback(false, LauncherException("No launchable update found")) return } launchedUpdate = selectedUpdate launchUpdate(selectedUpdate, callback) } catch (e: Exception) { callback(false, e) } } /** * Launch a specific update */ fun launch(update: PulseUpdate, callback: (success: Boolean, error: Exception?) -> Unit) { launchedUpdate = update launchUpdate(update, callback) } private fun launchUpdate(update: PulseUpdate, callback: (success: Boolean, error: Exception?) -> Unit) { // Mark update as accessed try { database.markUpdateAccessed(update.updateId) } catch (e: Exception) { pulseLogWarn(TAG, "Failed to mark update accessed: ${e.message}") } // Handle embedded updates if (update.status == PulseUpdateStatus.EMBEDDED) { launchEmbeddedUpdate(update, callback) return } // Handle development updates (no assets to verify) if (update.status == PulseUpdateStatus.DEVELOPMENT) { callback(true, null) return } // Handle regular updates - ensure all assets exist ensureAllAssetsExist(update, callback) } private fun launchEmbeddedUpdate(update: PulseUpdate, callback: (success: Boolean, error: Exception?) -> Unit) { // For embedded updates, DON'T set launchAssetFile // Let React Native use its default bundle loading mechanism // This ensures images from drawable folders are resolved correctly launchAssetFile = null assetFilesMap = emptyMap() callback(true, null) } private fun ensureAllAssetsExist(update: PulseUpdate, callback: (success: Boolean, error: Exception?) -> Unit) { try { val assets = database.assetsForUpdate(update.updateId) pulseLog(TAG, "ensureAllAssetsExist for updateId=${update.updateId.take(16)}... found ${assets.size} assets") // Initialize asset map with embedded assets (these are referenced directly, not copied) val mutableAssetFilesMap = buildEmbeddedAssetsMap().toMutableMap() pulseLog(TAG, "Embedded assets map has ${mutableAssetFilesMap.size} entries") if (assets.isEmpty()) { // No assets to verify, just find the bundle update.bundleHash?.let { hash -> launchAssetFile = bundleStorePath(hash) } callback(launchAssetFile?.exists() == true, if (launchAssetFile?.exists() != true) LauncherException("Bundle not found") else null) return } // Separate assets into those we need to verify vs those already in embedded val assetsToVerify = mutableListOf() for (asset in assets) { if (asset.isLaunchAsset) { // Always verify the launch asset (bundle) - it must be downloaded assetsToVerify.add(asset) } else { val hashKey = asset.hash.lowercase() // If asset hash is already in embedded map, we can use it directly if (mutableAssetFilesMap.containsKey(hashKey)) { // Already referenced from embedded, no action needed continue } // Check if downloaded version exists val localFile = assetStorePath(asset.hash) if (localFile.exists()) { // Use downloaded version val localUri = localFile.toURI().toString() mutableAssetFilesMap[hashKey] = localUri asset.key?.let { key -> mutableAssetFilesMap[key] = localUri } } else { // Need to verify/download this asset assetsToVerify.add(asset) } } } pulseLog(TAG, "${assets.size - assetsToVerify.size} assets from embedded, ${assetsToVerify.size} to verify/download") if (assetsToVerify.isEmpty()) { // All assets accounted for, but we need the bundle update.bundleHash?.let { hash -> launchAssetFile = bundleStorePath(hash) if (launchAssetFile?.exists() != true) { callback(false, LauncherException("Bundle not found")) return } } assetFilesMap = mutableAssetFilesMap callback(launchAssetFile != null, null) return } val totalAssets = assetsToVerify.size val completedAssets = java.util.concurrent.atomic.AtomicInteger(0) val launchError = AtomicReference(null) val launchAssetFound = AtomicBoolean(false) val latch = CountDownLatch(totalAssets) for (asset in assetsToVerify) { val localFile = assetStorePath(asset.hash) Thread { try { val exists = ensureAssetExists(asset, localFile) if (exists) { if (asset.isLaunchAsset) { launchAssetFile = localFile launchAssetFound.set(true) } else { val localUri = localFile.toURI().toString() synchronized(mutableAssetFilesMap) { val hashKey = asset.hash.lowercase() mutableAssetFilesMap[hashKey] = localUri asset.key?.let { key -> mutableAssetFilesMap[key] = localUri } } } } } catch (e: Exception) { if (asset.isLaunchAsset) { launchError.set(e) } } finally { completedAssets.incrementAndGet() latch.countDown() } }.start() } // Wait for all assets to be processed Thread { latch.await() assetFilesMap = mutableAssetFilesMap val success = launchAssetFound.get() callback(success, if (!success) (launchError.get() ?: LauncherException("Launch asset missing")) else null) }.start() } catch (e: Exception) { callback(false, e) } } /** * 3-step asset fallback: * 1. Check if asset exists at expected location * 2. Try to copy from APK assets * 3. Try to download from remote */ private fun ensureAssetExists(asset: PulseAsset, localFile: File): Boolean { // Step 1: Check if asset exists at expected location if (localFile.exists()) { return true } // Step 2: Try to copy from APK assets if (maybeCopyAssetFromBundle(asset, localFile)) { return true } // Step 3: Try to download the asset return downloadAsset(asset, localFile) } private fun maybeCopyAssetFromBundle(asset: PulseAsset, toFile: File): Boolean { // Load embedded manifest if not cached if (embeddedManifest == null) { embeddedManifest = loadEmbeddedManifest() } val embedded = embeddedManifest ?: return false // Find matching embedded asset by key or hash val matchingAsset = embedded.assets.find { embeddedAsset -> (asset.key != null && embeddedAsset.key == asset.key) || embeddedAsset.hash.equals(asset.hash, ignoreCase = true) } ?: return false val bundleFilename = matchingAsset.mainBundleFilename ?: return false try { // Get asset path in APK val assetPath = if (matchingAsset.mainBundleDir?.isNotEmpty() == true) { "${matchingAsset.mainBundleDir}/$bundleFilename" } else { bundleFilename } // Copy from APK assets context.assets.open(assetPath).use { input -> toFile.parentFile?.mkdirs() FileOutputStream(toFile).use { output -> input.copyTo(output) } } return true } catch (e: Exception) { pulseLogWarn(TAG, "Failed to copy asset from bundle: ${e.message}") return false } } private fun downloadAsset(asset: PulseAsset, toFile: File): Boolean { val remoteUrl = asset.url ?: return false try { toFile.parentFile?.mkdirs() URL(remoteUrl).openStream().use { input -> FileOutputStream(toFile).use { output -> input.copyTo(output) } } // Verify hash if expected asset.expectedHash?.let { expectedHash -> val actualHash = sha256Hex(toFile) if (actualHash != expectedHash) { toFile.delete() pulseLogError(TAG, "Asset hash mismatch: expected $expectedHash, got $actualHash") return false } } return true } catch (e: Exception) { pulseLogError(TAG, "Failed to download asset: ${e.message}") toFile.delete() return false } } // MARK: - Asset Path Helpers private fun assetStorePath(hash: String): File { return File(directory, "assets/sha256/${hash.lowercase()}") } private fun bundleStorePath(hash: String): File { // Bundle is stored in assets directory (same location as other assets) return File(directory, "assets/sha256/${hash.lowercase()}") } // MARK: - Embedded Assets Map private fun buildEmbeddedAssetsMap(): Map { val embedded = embeddedManifest ?: loadEmbeddedManifest() ?: return emptyMap() val result = mutableMapOf() for (asset in embedded.assets) { if (asset.isLaunchAsset) continue val filename = asset.mainBundleFilename ?: continue val assetPath = if (asset.mainBundleDir?.isNotEmpty() == true) { "${asset.mainBundleDir}/$filename" } else { filename } // Verify asset exists in APK try { context.assets.open(assetPath).close() val localUri = "asset://$assetPath" // Store by SHA256 hash result[asset.hash] = localUri // Also store by path-based key for Metro compatibility asset.key?.let { key -> result[key] = localUri } } catch (e: Exception) { // Asset doesn't exist in bundle } } return result } private fun buildEmbeddedAssetFilesMap(): Map { return buildEmbeddedAssetsMap() } // MARK: - Embedded Manifest private fun loadEmbeddedManifest(): EmbeddedManifest? { // Try pulse/ subdirectory first, then root val paths = listOf("pulse/embedded-manifest.json", "embedded-manifest.json") for (path in paths) { try { val json = context.assets.open(path).bufferedReader().use { it.readText() } return EmbeddedManifest.fromJson(json) } catch (e: Exception) { // Try next path } } pulseLogWarn(TAG, "No embedded manifest found") return null } // MARK: - Crypto private fun sha256Hex(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { fis -> val buffer = ByteArray(8192) var bytesRead: Int while (fis.read(buffer).also { bytesRead = it } != -1) { digest.update(buffer, 0, bytesRead) } } return digest.digest().joinToString("") { "%02x".format(it) } } } // MARK: - Launcher Exception class LauncherException(message: String) : Exception(message) // MARK: - Embedded Manifest Model data class EmbeddedManifest( val updateId: String, val runtimeVersion: String, val commitTime: Long, val assets: List ) { companion object { fun fromJson(json: String): EmbeddedManifest? { return try { val root = org.json.JSONObject(json) // Support both formats: wrapped in "manifest" key or directly at root val manifest = root.optJSONObject("manifest") ?: root val updateId = manifest.getString("updateId") val runtimeVersion = manifest.getString("runtimeVersion") // Support both commitTime (ms) and createdAt (ISO 8601) val commitTime = if (manifest.has("commitTime")) { manifest.optLong("commitTime", System.currentTimeMillis()) } else if (manifest.has("createdAt")) { try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US) .parse(manifest.getString("createdAt"))?.time ?: System.currentTimeMillis() } catch (e: Exception) { System.currentTimeMillis() } } else { System.currentTimeMillis() } val assets = mutableListOf() // Parse assets array manifest.optJSONArray("assets")?.let { assetsJson -> for (i in 0 until assetsJson.length()) { EmbeddedAsset.fromJson(assetsJson.getJSONObject(i))?.let { assets.add(it) } } } // Parse launch asset (either separate or from bundle field) manifest.optJSONObject("launchAsset")?.let { launchAssetJson -> EmbeddedAsset.fromJson(launchAssetJson, isLaunchAsset = true)?.let { assets.add(it) } } ?: manifest.optJSONObject("bundle")?.let { bundleJson -> val bundleHash = bundleJson.optString("hash", "") if (bundleHash.isNotEmpty()) { assets.add(EmbeddedAsset( key = "bundle", hash = bundleHash, type = bundleJson.optString("contentType", null), mainBundleDir = null, mainBundleFilename = null, isLaunchAsset = true )) } } EmbeddedManifest(updateId, runtimeVersion, commitTime, assets) } catch (e: Exception) { pulseLogError("EmbeddedManifest", "Failed to parse: ${e.message}") null } } } } data class EmbeddedAsset( val key: String?, val hash: String, val type: String?, val mainBundleDir: String?, val mainBundleFilename: String?, val isLaunchAsset: Boolean = false ) { companion object { fun fromJson(json: org.json.JSONObject, isLaunchAsset: Boolean = false): EmbeddedAsset? { val hash = json.optString("hash") ?: return null return EmbeddedAsset( key = json.optString("key", null), hash = hash, type = json.optString("type", null) ?: json.optString("contentType", null), mainBundleDir = json.optString("mainBundleDir", null), mainBundleFilename = json.optString("mainBundleFilename", null), isLaunchAsset = isLaunchAsset ) } } } // MARK: - Selection Policy interface PulseSelectionPolicy { fun selectUpdateToLaunch(updates: List): PulseUpdate? fun selectUpdatesToDelete(launchedUpdate: PulseUpdateInfo, allUpdates: List): List } data class PulseUpdateInfo( val updateId: String, val runtimeVersion: String, val commitTime: Long, val status: PulseUpdateStatus, val successfulLaunchCount: Int = 0, val failedLaunchCount: Int = 0, val isEmbedded: Boolean = false, val bundleHash: String? = null, val scopeKey: String? = null ) class PulseDefaultSelectionPolicy : PulseSelectionPolicy { /** * Select the best update to launch * Priority: 1) Ready updates (newest by commitTime), 2) Embedded updates (fallback) */ override fun selectUpdateToLaunch(updates: List): PulseUpdate? { // Exclude updates that failed to launch and were never confirmed good // (failedLaunchCount > 0 && successfulLaunchCount == 0) so a bad update can't be // re-selected on every launch — a crash loop that can effectively brick the app. // Mirrors iOS PulseDefaultSelectionPolicy.selectUpdateToLaunch. val healthy = updates.filter { !(it.failedLaunchCount > 0 && it.successfulLaunchCount == 0) } // First, try to find the newest ready (downloaded) healthy update val readyUpdates = healthy.filter { it.status == PulseUpdateStatus.READY } val newestReady = readyUpdates.maxByOrNull { it.commitTime.time } if (newestReady != null) { return newestReady } // Fall back to embedded update (embedded is always launchable). return healthy.find { it.status == PulseUpdateStatus.EMBEDDED } ?: updates.find { it.status == PulseUpdateStatus.EMBEDDED } } override fun selectUpdatesToDelete(launchedUpdate: PulseUpdateInfo, allUpdates: List): List { val toDelete = mutableListOf() var newestBackup: PulseUpdateInfo? = null for (update in allUpdates) { // Never delete the currently launched update if (update.updateId == launchedUpdate.updateId) continue // Never delete embedded updates (handled by integrity check) if (update.isEmbedded) continue // Mark older updates for deletion, but track the newest as potential backup if (update.commitTime < launchedUpdate.commitTime) { toDelete.add(update) if (newestBackup == null || update.commitTime > newestBackup.commitTime) { newestBackup = update } } } // Keep one backup (the newest older update) for rollback capability newestBackup?.let { backup -> toDelete.removeAll { it.updateId == backup.updateId } } return toDelete } }