package app.pulse.updates import android.content.Context 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 app.pulse.updates.launcher.PulseAppLauncher import app.pulse.updates.launcher.PulseDefaultSelectionPolicy import app.pulse.updates.launcher.PulseSelectionPolicy import app.pulse.updates.launcher.PulseUpdateInfo import app.pulse.updates.launcher.EmbeddedManifest import app.pulse.updates.errorrecovery.PulseErrorRecovery import app.pulse.updates.errorrecovery.PulseErrorRecoveryDelegate import org.json.JSONObject import java.io.File import java.net.URL import java.security.MessageDigest import java.util.Date import java.util.concurrent.Executors // Logging - always prints for debugging OTA issues // Debug flag - set via PULSE_DEBUG environment variable or BuildConfig internal var PULSE_DEBUG: Boolean = System.getenv("PULSE_DEBUG")?.toBoolean() ?: false internal fun pulseLog(tag: String, message: String) { if (PULSE_DEBUG) { println("[$tag] $message") } } internal fun pulseLogWarn(tag: String, message: String) { if (PULSE_DEBUG) { println("[$tag] WARN: $message") } } internal fun pulseLogError(tag: String, message: String) { // Always log errors println("[$tag] ERROR: $message") } /** * PulseUpdates Controller * Based on expo-updates AppController * Main controller that orchestrates update lifecycle */ class PulseController private constructor() { companion object { private const val TAG = "PulseController" @Volatile private var instance: PulseController? = null fun getInstance(): PulseController { return instance ?: synchronized(this) { instance ?: PulseController().also { instance = it } } } } interface Delegate { fun onStarted(success: Boolean) } // Public properties var delegate: Delegate? = null var isActive: Boolean = false private set var isStarted: Boolean = false private set var config: PulseUpdatesConfig? = null private set var launchedUpdate: PulseUpdateInfo? = null private set var isEmbeddedLaunch: Boolean = true private set var launchAssetFile: File? = null private set var localAssets: Map = emptyMap() private set // The manifest JSON for the launched update (for metadata like build number) var launchedManifestJson: String? = null private set // Error recovery var errorRecovery: PulseErrorRecovery? = null private set var remoteLoadStatus: PulseErrorRecoveryDelegate.RemoteLoadStatus = PulseErrorRecoveryDelegate.RemoteLoadStatus.IDLE private set // Private properties private var context: Context? = null private var database: PulseDatabase? = null private var launcher: PulseAppLauncher? = null private var directory: File? = null private var embeddedManifest: EmbeddedManifest? = null private val executor = Executors.newSingleThreadExecutor() // Stable, anonymous per-install device id (persisted in the pulse dir). Lazily generated. // Used to (a) bucket staged rollouts server-side via the Pulse-Device-Id header and // (b) attribute device telemetry events. Failures to read/persist are logged, never silent. private var _deviceId: String? = null val deviceId: String get() { _deviceId?.let { return it } val dir = directory val resolved = if (dir != null) { val f = File(dir, "device_id") var existing: String? = null if (f.exists()) { try { existing = f.readText().trim().ifEmpty { null } } catch (e: Exception) { pulseLogWarn(TAG, "deviceId: read failed (${e.message}); regenerating") } } existing ?: java.util.UUID.randomUUID().toString().also { id -> try { f.writeText(id) } catch (e: Exception) { pulseLogWarn(TAG, "deviceId: persist failed (${e.message}); a new id will be used next launch") } } } else { java.util.UUID.randomUUID().toString() } _deviceId = resolved return resolved } // Cached manifest from last check (to avoid duplicate requests in fetch) // Only valid for the immediate check->fetch sequence, cleared on use or new check private var lastCheckManifest: ManifestModel? = null // Embedded asset hashes set for download optimization internal val embeddedAssetHashes: Set get() = embeddedManifest?.assets ?.filter { !it.isLaunchAsset } ?.map { it.hash.lowercase() } ?.toSet() ?: emptySet() // Native runtime version (from config, embedded manifest, or app version) private val nativeRuntimeVersion: String get() { // Use config if available config?.runtimeVersion?.let { return it } // Try embedded manifest embeddedManifest?.runtimeVersion?.let { return it } // Fall back to app version return try { val ctx = context ?: return "unknown" val packageInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0) packageInfo.versionName ?: packageInfo.versionCode.toString() } catch (e: Exception) { "unknown" } } /** * Load config from AndroidManifest.xml metadata (like expo-updates) */ private fun loadNativeConfig(): PulseUpdatesConfig? { val ctx = context ?: return null try { val appInfo = ctx.packageManager.getApplicationInfo( ctx.packageName, android.content.pm.PackageManager.GET_META_DATA ) val metadata = appInfo.metaData ?: return null // Check if PulseUpdates is configured val updateUrl = metadata.getString("PulseUpdatesURL") if (updateUrl.isNullOrEmpty()) { pulseLog(TAG, "No PulseUpdatesURL in AndroidManifest.xml, native config disabled") return null } val enabled = metadata.getBoolean("PulseUpdatesEnabled", true) // Runtime version: embedded manifest > versionName val runtimeVersion = embeddedManifest?.runtimeVersion ?: try { val packageInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0) packageInfo.versionName ?: packageInfo.versionCode.toString() } catch (e: Exception) { "unknown" } return PulseUpdatesConfig( enabled = enabled, updateUrl = updateUrl, runtimeVersion = runtimeVersion, checkOnLaunch = metadata.getString("PulseUpdatesCheckOnLaunch") ?: "ALWAYS", launchWaitMs = metadata.getInt("PulseUpdatesLaunchWaitMs", 0), channel = metadata.getString("PulseUpdatesChannel"), signingKeyId = metadata.getString("PulseUpdatesSigningKeyId"), signingPublicKey = metadata.getString("PulseUpdatesSigningPublicKey"), requireSignature = metadata.getBoolean("PulseUpdatesRequireSignature", true) ) } catch (e: Exception) { pulseLogWarn(TAG, "Failed to load native config: ${e.message}") return null } } // MARK: - Public API /** * Initialize context and directory without full config * Called by getBundleFile before JS starts */ fun initializeContext(context: Context) { if (this.context == null) { this.context = context.applicationContext this.directory = File(context.filesDir, "pulse") } } fun configure(context: Context, config: PulseUpdatesConfig) { this.context = context.applicationContext this.config = config this.isActive = config.enabled this.directory = File(context.filesDir, "pulse") } fun initializeWithoutStarting() { if (isStarted) return // Mark as started to prevent re-initialization during reload isStarted = true createDirectories() initializeDatabase() loadEmbeddedManifest() // Load native config from AndroidManifest.xml if not already configured by JS if (config == null) { config = loadNativeConfig() config?.let { cfg -> isActive = cfg.enabled pulseLog(TAG, "Loaded native config: url=${cfg.updateUrl} runtime=${cfg.runtimeVersion}") } } seedEmbeddedUpdateIfNeeded() runIntegrityCheck() // Synchronously select the best update for launch selectBestUpdateSync() // Arm crash-rollback on the cold-start path too (parity with start() and iOS). The documented // getBundleFile integration goes through here, and initializeWithoutStarting() sets // isStarted=true, so a later start() early-returns and would never install recovery — it must // be done here or a bad OTA could never auto-roll-back on the documented Android path. if (errorRecovery == null) { errorRecovery = PulseErrorRecovery().also { it.initialize(errorRecoveryDelegate) it.startMonitoring() } } // Mark the launched OTA update as "launch in progress" so the health/recovery bookkeeping // (consecutive failures, success/failure counts) is correct on this path. launchedUpdate?.takeIf { !isEmbeddedLaunch }?.let { database?.recordLaunchStart(it.updateId) } } // Synchronously select the best update (used for initial launch before JS starts) private fun selectBestUpdateSync() { val db = database // Use config.runtimeVersion if available, otherwise fall back to native val runtimeVersion = config?.runtimeVersion ?: nativeRuntimeVersion pulseLog(TAG, "selectBestUpdateSync: database=${db != null} runtimeVersion=$runtimeVersion") if (db == null) { pulseLog(TAG, "selectBestUpdateSync: no database") return } try { val updates = db.launchableUpdates(runtimeVersion) pulseLog(TAG, "selectBestUpdateSync: found ${updates.size} updates") val policy = PulseDefaultSelectionPolicy() val selectedUpdate = policy.selectUpdateToLaunch(updates) if (selectedUpdate == null) { pulseLog(TAG, "selectBestUpdateSync: no update selected") return } pulseLog(TAG, "selectBestUpdateSync: selected ${selectedUpdate.updateId} status=${selectedUpdate.status} bundleHash=${selectedUpdate.bundleHash}") // Set the launch file based on selected update if (selectedUpdate.status == PulseUpdateStatus.EMBEDDED) { // For embedded bundles, DON'T set launchAssetFile // Let React Native use its default bundle loading mechanism // This ensures images from drawable folders are resolved correctly launchAssetFile = null isEmbeddedLaunch = true // For embedded, localAssets can be empty - React Native handles it localAssets = emptyMap() pulseLog(TAG, "selectBestUpdateSync: using embedded bundle (default RN loading)") } else if (selectedUpdate.bundleHash != null) { val bundlePath = File(directory, "assets/sha256/${selectedUpdate.bundleHash.lowercase()}") val exists = bundlePath.exists() pulseLog(TAG, "selectBestUpdateSync: bundlePath exists=$exists") if (exists) { launchAssetFile = bundlePath isEmbeddedLaunch = false // Set launchedUpdate info launchedUpdate = PulseUpdateInfo( updateId = selectedUpdate.updateId, runtimeVersion = selectedUpdate.runtimeVersion, commitTime = selectedUpdate.commitTime.time, status = selectedUpdate.status, successfulLaunchCount = selectedUpdate.successfulLaunchCount, failedLaunchCount = selectedUpdate.failedLaunchCount, isEmbedded = selectedUpdate.isEmbedded, bundleHash = selectedUpdate.bundleHash, scopeKey = selectedUpdate.scopeKey ) // Load manifest from database for metadata (build number, etc.) selectedUpdate.manifest?.let { manifest -> launchedManifestJson = manifest.toString() pulseLog(TAG, "selectBestUpdateSync: loaded manifest json") } // Build localAssets: downloaded assets + embedded fallbacks localAssets = buildLocalAssetsForUpdate(selectedUpdate.updateId, db) pulseLog(TAG, "selectBestUpdateSync: set launchAssetFile to downloaded bundle, localAssets count=${localAssets.size}") } } } catch (e: Exception) { pulseLogError(TAG, "selectBestUpdateSync error: ${e.message}") } } // Build localAssets map from embedded manifest for fallback // Store BOTH hash AND path-based keys for maximum compatibility: // - hash: for SHA256 hash lookup // - path: for Metro path-based lookup (httpServerLocation/name.type) private fun buildEmbeddedLocalAssets(): Map { val ctx = context ?: return emptyMap() val manifest = embeddedManifest ?: return emptyMap() val assets = mutableMapOf() for (asset in manifest.assets) { if (asset.isLaunchAsset) continue // Try to find the asset in the assets folder val assetPath = asset.mainBundleFilename?.let { filename -> val dir = asset.mainBundleDir if (dir != null && dir.isNotEmpty()) { "$dir/$filename" } else { filename } } if (assetPath != null) { try { // Verify the asset exists ctx.assets.open(assetPath).close() val localUri = "asset:///$assetPath" // Store by manifest key (MD5 hash - matches how expo-asset looks up) asset.key?.let { key -> assets[key] = localUri } // Also store by SHA256 hash for fallback assets[asset.hash] = localUri } catch (e: Exception) { // Asset doesn't exist in bundle } } } pulseLog(TAG, "buildEmbeddedLocalAssets: found ${assets.size} entries") return assets } // Build localAssets map for an OTA update (downloaded + embedded fallbacks) // Store BOTH hash AND path-based keys for maximum compatibility private fun buildLocalAssetsForUpdate(updateId: String, database: PulseDatabase): Map { // Start with embedded assets as base (fallback) val assets = buildEmbeddedLocalAssets().toMutableMap() pulseLog(TAG, "buildLocalAssetsForUpdate: embedded base count=${assets.size}") val dir = directory ?: return assets // Get downloaded assets for this update from database try { val updateAssets = database.assetsForUpdate(updateId) pulseLog(TAG, "buildLocalAssetsForUpdate: found ${updateAssets.size} assets in database for update $updateId") for (asset in updateAssets) { if (asset.isLaunchAsset) continue // Check if downloaded asset exists (stored by SHA256 hash) val downloadedPath = File(dir, "assets/sha256/${asset.hash.lowercase()}") if (downloadedPath.exists()) { val localUri = "file://${downloadedPath.absolutePath}" // Store by manifest key (MD5 hash - matches how expo-asset looks up) asset.key?.let { key -> assets[key] = localUri } // Also store by SHA256 hash for fallback assets[asset.hash] = localUri } else { // Downloaded file doesn't exist - use embedded fallback // BUT we still need to add the MD5 key from OTA manifest pointing to the embedded asset! // The embedded map has path-based keys, but JS looks up by MD5 hash val embeddedUri = assets[asset.hash] // Try SHA256 hash (embedded has both hash and path keys) if (embeddedUri != null && asset.key != null) { // Add MD5 key pointing to embedded asset assets[asset.key!!] = embeddedUri pulseLog(TAG, "buildLocalAssetsForUpdate: using embedded fallback for key=${asset.key?.take(16)}... hash=${asset.hash.take(16)}...") } else { pulseLogWarn(TAG, "buildLocalAssetsForUpdate: file not found AND no embedded fallback for hash=${asset.hash.take(16)}...") } } } } catch (e: Exception) { pulseLogError(TAG, "buildLocalAssetsForUpdate error: ${e.message}") } return assets } fun start() { if (isStarted) return isStarted = true pulseLog(TAG, "Starting controller") createDirectories() initializeDatabase() loadEmbeddedManifest() // Load native config from AndroidManifest.xml if not already configured by JS if (config == null) { config = loadNativeConfig() config?.let { cfg -> isActive = cfg.enabled pulseLog(TAG, "Loaded native config: url=${cfg.updateUrl} runtime=${cfg.runtimeVersion}") } } seedEmbeddedUpdateIfNeeded() runIntegrityCheck() // Initialize error recovery errorRecovery = PulseErrorRecovery().also { it.initialize(errorRecoveryDelegate) it.startMonitoring() } // Launch best available update launchBestUpdate { success -> if (success) { pulseLog(TAG, "Started successfully with update: ${launchedUpdate?.updateId ?: "embedded"}") } else { pulseLogWarn(TAG, "Failed to start, falling back to embedded") launchEmbedded() } delegate?.onStarted(success || launchAssetFile != null) // Check for updates on launch based on config performCheckOnLaunchIfNeeded() } } // MARK: - Check on Launch private fun performCheckOnLaunchIfNeeded() { val cfg = config ?: return if (!cfg.enabled) return when (cfg.checkOnLaunch.uppercase()) { "NEVER" -> { pulseLog(TAG, "checkOnLaunch is NEVER, skipping automatic check") return } "WIFI_ONLY" -> { if (!isOnWifi()) { pulseLog(TAG, "checkOnLaunch is WIFI_ONLY but not on WiFi, skipping") return } } } // Wait for launchWaitMs before checking val waitMs = cfg.launchWaitMs.toLong() executor.execute { if (waitMs > 0) { Thread.sleep(waitMs) } performBackgroundUpdateCheck() } } private fun performBackgroundUpdateCheck() { pulseLog(TAG, "Performing background update check") checkForUpdate { result -> result.fold( onSuccess = { checkResult -> if (checkResult.isAvailable) { pulseLog(TAG, "Background check: update available, fetching...") fetchUpdate { fetchResult -> fetchResult.fold( onSuccess = { fetch -> if (fetch.isNew) { pulseLog(TAG, "Background check: update downloaded and ready") // Could emit event to JS here } }, onFailure = { error -> pulseLogWarn(TAG, "Background check: fetch failed - ${error.message}") } ) } } else { pulseLog(TAG, "Background check: no update available") } }, onFailure = { error -> pulseLogWarn(TAG, "Background check failed: ${error.message}") } ) } } private fun isOnWifi(): Boolean { val ctx = context ?: return false val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return false val network = connectivityManager.activeNetwork ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false return capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) } fun getBundleFile(): File? { // Only return a custom bundle path if we have a downloaded OTA update // For embedded bundles, return null to let React Native use its default mechanism // This ensures images from drawable folders work correctly val launchFile = launchAssetFile if (launchFile != null && launchFile.exists()) { return launchFile } // Return null to use default bundle loading (from APK assets) return null } // True when the host app is built debuggable. This is the runtime equivalent of iOS #if DEBUG: // this module is an android-library, so its own BuildConfig.DEBUG reflects the library build // type, not the host app. FLAG_DEBUGGABLE on the host context is authoritative. private fun isHostDebuggable(): Boolean { val ctx = context ?: return false return (ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 } // Refuse remote updates when a release build requires signing but no public key is configured. // Debug builds are allowed to run unsigned remote updates for local iteration. private fun shouldRefuseRemoteUpdates(cfg: PulseUpdatesConfig): Boolean { if (isHostDebuggable()) return false return cfg.requireSignature && cfg.signingPublicKey.isNullOrEmpty() } fun checkForUpdate(callback: (Result) -> Unit) { val cfg = config if (cfg == null) { callback(Result.failure(PulseUpdatesException("Not configured"))) return } if (!cfg.enabled) { callback(Result.failure(PulseUpdatesException("Updates not enabled"))) return } // Fail-closed: in non-debug builds, refuse remote updates when signing is not // configured. Falls back to the embedded bundle (no remote install). if (shouldRefuseRemoteUpdates(cfg)) { pulseLogError(TAG, "checkForUpdate: signing required but not configured in a release build; " + "refusing remote updates and using embedded bundle") callback(Result.failure(PulseUpdatesException("Signing required but not configured"))) return } executor.execute { // Clear any previous cached manifest before new check lastCheckManifest = null PulseRemoteLoader.checkForUpdate(cfg, launchedUpdate?.updateId) { result -> // Cache the manifest if update is available (for use by fetchUpdate) result.fold( onSuccess = { checkResult -> if (checkResult.isAvailable && checkResult.manifest != null) { // Compare commitTime: if embedded is newer, don't show update val embeddedCommitTime = embeddedManifest?.commitTime ?: 0L val otaCommitTime = checkResult.manifest.commitTime?.time ?: 0L if (embeddedCommitTime > 0 && otaCommitTime > 0 && embeddedCommitTime >= otaCommitTime) { pulseLog(TAG, "checkForUpdate: embedded is newer or same (embedded=$embeddedCommitTime >= ota=$otaCommitTime), no update needed") callback(Result.success(CheckResult(isAvailable = false))) return@checkForUpdate } lastCheckManifest = checkResult.manifest reportEvent("check", checkResult.manifest.updateId) pulseLog(TAG, "checkForUpdate: cached manifest for fetch (ota=$otaCommitTime > embedded=$embeddedCommitTime)") } }, onFailure = { } ) callback(result) } } } fun fetchUpdate(cachedManifest: ManifestModel? = null, callback: (Result) -> Unit) { val cfg = config val ctx = context val dir = directory if (cfg == null || ctx == null || dir == null) { callback(Result.failure(PulseUpdatesException("Not configured"))) return } if (!cfg.enabled) { callback(Result.failure(PulseUpdatesException("Updates not enabled"))) return } // Fail-closed: never download/install a remote update in a release build that // lacks signing configuration (covers the cachedManifest fast-path that skips check). if (shouldRefuseRemoteUpdates(cfg)) { pulseLogError(TAG, "fetchUpdate: signing required but not configured in a release build; " + "refusing remote updates and using embedded bundle") callback(Result.failure(PulseUpdatesException("Signing required but not configured"))) return } // Use cached manifest from recent check if available var manifestToUse = cachedManifest if (manifestToUse == null && lastCheckManifest != null) { pulseLog(TAG, "fetchUpdate: using cached manifest from recent check") manifestToUse = lastCheckManifest } // Clear the cache after using it lastCheckManifest = null executor.execute { PulseRemoteLoader.fetchUpdate(cfg, database, dir, manifestToUse) { result -> // A freshly downloaded update marks this device as "served" on the server, which is // what gates its launch outcomes into the crash-rate auto-rollback. result.onSuccess { fr -> if (fr.isNew) reportEvent("download", fr.manifest?.updateId) } callback(result) } } } fun reload(callback: (Boolean) -> Unit) { // Re-select the best update before reloading launchBestUpdate { success -> pulseLog(TAG, "Reload: launchBestUpdate success=$success") callback(success) } } fun markAppReady() { launchedUpdate?.let { update -> try { database?.recordSuccessfulLaunch(update.updateId) } catch (e: Exception) { pulseLogWarn(TAG, "Failed to record successful launch: ${e.message}") } if (!isEmbeddedLaunch) reportEvent("launch_success", update.updateId) } // Run reaper after successful launch runReaper() } fun reportLaunchFailure(reason: String) { launchedUpdate?.let { update -> try { database?.recordFailedLaunch(update.updateId) } catch (e: Exception) { pulseLogWarn(TAG, "Failed to record launch failure: ${e.message}") } if (!isEmbeddedLaunch) reportEvent("launch_failure", update.updateId) } } /** * Fire-and-forget device telemetry to the server's /pulse/events endpoint, on a daemon thread so * it never blocks update work. Best-effort: a failure is logged, never silently swallowed, and * never affects the app. The server marks a device "served" on the "download" event — only served * devices count toward crash-rate auto-rollback — and uses launch_success/launch_failure to drive * that rate. appSlug + base are derived from the manifest URL (.../pulse/manifest/{appSlug}). */ private fun reportEvent(type: String, updateId: String?) { if (updateId.isNullOrEmpty()) return val cfg = config ?: return val marker = "/pulse/manifest/" val idx = cfg.updateUrl.indexOf(marker) if (idx < 0) { pulseLogWarn(TAG, "reportEvent($type): updateUrl '${cfg.updateUrl}' has no $marker — cannot derive events endpoint, skipping") return } val base = cfg.updateUrl.substring(0, idx) val appSlug = cfg.updateUrl.substring(idx + marker.length).substringBefore('/').substringBefore('?') val eventsUrl = "$base/pulse/events" val device = deviceId Thread({ try { val conn = URL(eventsUrl).openConnection() as java.net.HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.connectTimeout = 5000 conn.readTimeout = 5000 conn.doOutput = true val body = JSONObject() .put("appSlug", appSlug) .put("updateId", updateId) .put("deviceId", device) .put("type", type) .toString() conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } val code = conn.responseCode if (code !in 200..299) pulseLogWarn(TAG, "reportEvent($type) -> HTTP $code") conn.disconnect() } catch (e: Exception) { pulseLogWarn(TAG, "reportEvent($type) failed: ${e.message}") } }, "pulse-telemetry").apply { isDaemon = true }.start() } // MARK: - Private Methods private fun createDirectories() { val dir = directory ?: return listOf( dir, File(dir, "updates"), File(dir, "assets/sha256"), File(dir, "bundles/sha256"), File(dir, "staging") ).forEach { it.mkdirs() } } private fun initializeDatabase() { val ctx = context ?: return val dir = directory ?: return try { database = PulseDatabase(ctx, dir) pulseLog(TAG, "Database initialized") } catch (e: Exception) { pulseLogError(TAG, "Failed to initialize database: ${e.message}") } } private fun loadEmbeddedManifest() { val ctx = context ?: return // Try pulse/ subdirectory first, then root val paths = listOf("pulse/embedded-manifest.json", "embedded-manifest.json") for (path in paths) { try { val json = ctx.assets.open(path).bufferedReader().use { it.readText() } embeddedManifest = EmbeddedManifest.fromJson(json) pulseLog(TAG, "Loaded embedded manifest from $path: ${embeddedManifest?.updateId}") return } catch (e: Exception) { // Try next path } } pulseLog(TAG, "No embedded manifest found") } private fun seedEmbeddedUpdateIfNeeded() { val embedded = embeddedManifest ?: return val db = database ?: return val embeddedId = "embedded:${embedded.updateId}" // Check if already seeded try { if (db.getUpdate(embeddedId) != null) return } catch (e: Exception) { // Continue to seed } // Get embedded bundle hash val bundleHash = embedded.assets.find { it.isLaunchAsset }?.hash?.lowercase() // scopeKey from config is optional - may be nil before JS configures us val update = PulseUpdate( updateId = embeddedId, scopeKey = config?.scopeKey ?: "default", runtimeVersion = embedded.runtimeVersion, commitTime = Date(embedded.commitTime), status = PulseUpdateStatus.EMBEDDED, manifest = null, bundleHash = bundleHash, lastAccessed = Date(), successfulLaunchCount = 0, failedLaunchCount = 0 ) try { db.addUpdate(update) pulseLog(TAG, "Seeded embedded update: $embeddedId") } catch (e: Exception) { pulseLogWarn(TAG, "Failed to seed embedded update: ${e.message}") } } private fun runIntegrityCheck() { val db = database ?: return val currentEmbeddedId = embeddedManifest?.let { "embedded:${it.updateId}" } executor.execute { PulseIntegrityCheck.run(db, directory!!, currentEmbeddedId) } } private fun launchBestUpdate(callback: (Boolean) -> Unit) { val db = database ?: run { callback(false) return } val cfg = config ?: run { callback(false) return } val ctx = context ?: run { callback(false) return } val dir = directory ?: run { callback(false) return } val launcher = PulseAppLauncher( context = ctx, database = db, directory = dir, selectionPolicy = PulseDefaultSelectionPolicy(), runtimeVersion = cfg.runtimeVersion ) this.launcher = launcher launcher.launch { success, error -> if (success) { launchAssetFile = launcher.launchAssetFile localAssets = launcher.assetFilesMap isEmbeddedLaunch = launcher.isUsingEmbeddedAssets launcher.launchedUpdate?.let { update -> launchedUpdate = PulseUpdateInfo( updateId = update.updateId, runtimeVersion = update.runtimeVersion, commitTime = update.commitTime.time, status = update.status, successfulLaunchCount = update.successfulLaunchCount, failedLaunchCount = update.failedLaunchCount, isEmbedded = update.isEmbedded, bundleHash = update.bundleHash, scopeKey = update.scopeKey ) // Load manifest from database for metadata (build number, etc.) update.manifest?.let { manifest -> launchedManifestJson = manifest.toString() } } } error?.let { pulseLogWarn(TAG, "Launch error: ${it.message}") } callback(success) } } private fun launchEmbedded() { // For embedded bundles, DON'T set launchAssetFile // Let React Native use its default bundle loading mechanism // This ensures images from drawable folders are resolved correctly launchAssetFile = null isEmbeddedLaunch = true localAssets = emptyMap() } private fun runReaper() { val db = database ?: return val dir = directory ?: return val launchedId = launchedUpdate?.updateId ?: return executor.execute { PulseReaper.reap(db, dir, PulseDefaultSelectionPolicy(), launchedId) } } // MARK: - Error Recovery Delegate private val errorRecoveryDelegate = object : PulseErrorRecoveryDelegate { override fun loadRemoteUpdate() { remoteLoadStatus = PulseErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADING fetchUpdate { result -> result.fold( onSuccess = { fetchResult -> remoteLoadStatus = if (fetchResult.isNew) PulseErrorRecoveryDelegate.RemoteLoadStatus.NEW_UPDATE_LOADED else PulseErrorRecoveryDelegate.RemoteLoadStatus.IDLE }, onFailure = { remoteLoadStatus = PulseErrorRecoveryDelegate.RemoteLoadStatus.IDLE } ) errorRecovery?.notifyNewRemoteLoadStatus(remoteLoadStatus) } } override fun relaunch(callback: PulseErrorRecoveryDelegate.LauncherCallback) { launchBestUpdate { success -> if (success) { callback.onSuccess() } else { callback.onFailure(PulseUpdatesException("Failed to relaunch")) } } } override fun throwException(exception: Exception) { throw exception } override fun markFailedLaunchForLaunchedUpdate() { launchedUpdate?.updateId?.let { updateId -> try { database?.recordFailedLaunch(updateId) // Exclude this update from future launch selection: launchableUpdates() only // returns status IN ('ready','embedded'), so flipping a crashing OTA update to // FAILED makes the launcher (and selectBestUpdateSync) skip it and fall back to // the previous good/embedded update. Never demote the embedded update itself. if (launchedUpdate?.isEmbedded != true) { database?.setStatus(updateId, PulseUpdateStatus.FAILED) } } catch (e: Exception) { pulseLogWarn(TAG, "Failed to record failed launch: ${e.message}") } } } override fun markSuccessfulLaunchForLaunchedUpdate() { launchedUpdate?.updateId?.let { updateId -> try { database?.recordSuccessfulLaunch(updateId) } catch (e: Exception) { pulseLogWarn(TAG, "Failed to record successful launch: ${e.message}") } } } override fun getRemoteLoadStatus(): PulseErrorRecoveryDelegate.RemoteLoadStatus { return remoteLoadStatus } override fun getCheckOnLaunchConfiguration(): String { return config?.checkOnLaunch ?: "ALWAYS" } override fun getLaunchedUpdateSuccessfulLaunchCount(): Int { return launchedUpdate?.successfulLaunchCount ?: 0 } } } // MARK: - Config data class PulseUpdatesConfig( val enabled: Boolean, val updateUrl: String, val runtimeVersion: String, val checkOnLaunch: String = "ALWAYS", val launchWaitMs: Int = 0, val channel: String? = null, val signingKeyId: String? = null, val signingPublicKey: String? = null, // Fail-closed by default: require a valid Ed25519 signature on remote manifests. // Only meaningful for non-debug builds; debug builds always allow unsigned updates. val requireSignature: Boolean = true ) { val scopeKey: String get() = try { URL(updateUrl).host } catch (e: Exception) { "default" } } // MARK: - Results data class CheckResult( val isAvailable: Boolean, val manifest: ManifestModel? = null, val isRollback: Boolean = false, val failedReason: String? = null ) data class FetchResult( val isNew: Boolean, val manifest: ManifestModel? = null ) data class ManifestModel( val updateId: String, val runtimeVersion: String, val createdAt: String, val bundle: BundleModel, val assets: List, val metadata: Map? = null, val signature: SignatureModel? = null ) { val commitTime: Date? get() = try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).parse(createdAt) } catch (e: Exception) { try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US).parse(createdAt) } catch (e2: Exception) { null } } companion object { fun fromJson(json: String): ManifestModel? { return try { val obj = JSONObject(json) ManifestModel( updateId = obj.getString("updateId"), runtimeVersion = obj.getString("runtimeVersion"), createdAt = obj.getString("createdAt"), bundle = BundleModel( hash = obj.getJSONObject("bundle").getString("hash"), url = obj.getJSONObject("bundle").getString("url"), contentType = obj.getJSONObject("bundle").getString("contentType") ), assets = obj.optJSONArray("assets")?.let { arr -> (0 until arr.length()).map { i -> val asset = arr.getJSONObject(i) AssetModel( key = asset.optString("key", null), hash = asset.getString("hash"), url = asset.getString("url"), contentType = asset.getString("contentType"), name = asset.optString("name", null), type = asset.optString("type", null), scale = asset.optDouble("scale", 1.0), mainBundleDir = asset.optString("mainBundleDir", null), mainBundleFilename = asset.optString("mainBundleFilename", null) ) } } ?: emptyList(), metadata = obj.optJSONObject("metadata")?.let { meta -> val map = mutableMapOf() meta.keys().forEach { key -> map[key] = meta.optString(key, "") } map }, signature = obj.optJSONObject("signature")?.let { sig -> SignatureModel( alg = sig.getString("alg"), keyId = sig.getString("keyId"), sig = sig.getString("sig") ) } ) } catch (e: Exception) { null } } } } data class BundleModel( val hash: String, val url: String, val contentType: String ) data class AssetModel( val key: String?, val hash: String, val url: String, val contentType: String, val name: String? = null, val type: String? = null, val scale: Double? = null, val mainBundleDir: String? = null, val mainBundleFilename: String? = null ) data class SignatureModel( val alg: String, val keyId: String, val sig: String ) // MARK: - Exception class PulseUpdatesException(message: String) : Exception(message) // MARK: - Integrity Check object PulseIntegrityCheck { private const val TAG = "PulseIntegrityCheck" fun run(database: PulseDatabase, directory: File, currentEmbeddedId: String?) { try { // Delete old embedded updates val embeddedUpdates = database.embeddedUpdates() val toDelete = embeddedUpdates.filter { update -> currentEmbeddedId == null || update.updateId != currentEmbeddedId } if (toDelete.isNotEmpty()) { database.deleteUpdates(toDelete.map { it.updateId }) pulseLog(TAG, "Deleted ${toDelete.size} old embedded updates") // Clean up unused assets database.deleteUnusedAssets() } } catch (e: Exception) { pulseLogError(TAG, "IntegrityCheck error: ${e.message}") } } } // MARK: - Reaper object PulseReaper { private const val TAG = "PulseReaper" fun reap( database: PulseDatabase, directory: File, selectionPolicy: PulseSelectionPolicy, launchedUpdateId: String ) { try { val allUpdates = database.allUpdates() val launchedUpdate = allUpdates.find { it.updateId == launchedUpdateId } ?: return val launchedInfo = PulseUpdateInfo( updateId = launchedUpdate.updateId, runtimeVersion = launchedUpdate.runtimeVersion, commitTime = launchedUpdate.commitTime.time, status = launchedUpdate.status, successfulLaunchCount = launchedUpdate.successfulLaunchCount, failedLaunchCount = launchedUpdate.failedLaunchCount, isEmbedded = launchedUpdate.isEmbedded, bundleHash = launchedUpdate.bundleHash, scopeKey = launchedUpdate.scopeKey ) val allInfos = allUpdates.map { update -> PulseUpdateInfo( updateId = update.updateId, runtimeVersion = update.runtimeVersion, commitTime = update.commitTime.time, status = update.status, successfulLaunchCount = update.successfulLaunchCount, failedLaunchCount = update.failedLaunchCount, isEmbedded = update.isEmbedded, bundleHash = update.bundleHash, scopeKey = update.scopeKey ) } val toDeleteInfos = selectionPolicy.selectUpdatesToDelete(launchedInfo, allInfos) if (toDeleteInfos.isEmpty()) return val toDeleteIds = toDeleteInfos.map { it.updateId } database.deleteUpdates(toDeleteIds) // Delete unused assets from filesystem val unusedAssets = database.deleteUnusedAssets() for (asset in unusedAssets) { val assetPath = File(directory, "assets/sha256/${asset.hash.lowercase()}") assetPath.delete() } // Clean up unused bundles cleanupUnusedBundles(database, directory) pulseLog(TAG, "Reaper: Deleted ${toDeleteIds.size} updates, ${unusedAssets.size} assets") } catch (e: Exception) { pulseLogError(TAG, "Reaper error: ${e.message}") } } private fun cleanupUnusedBundles(database: PulseDatabase, directory: File) { // TODO: Implement proper cleanup based on database references } } // MARK: - Remote Loader object PulseRemoteLoader { private const val TAG = "PulseRemoteLoader" fun checkForUpdate( config: PulseUpdatesConfig, currentUpdateId: String?, callback: (Result) -> Unit ) { try { val url = URL(config.updateUrl) val connection = url.openConnection() as java.net.HttpURLConnection connection.requestMethod = "GET" connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("Pulse-Protocol-Version", "2") connection.setRequestProperty("X-Pulse-Platform", "android") connection.setRequestProperty("X-Pulse-Runtime-Version", config.runtimeVersion) // Stable device id so the server can bucket this device into staged rollouts (without it, // a device is held back from any partial rollout until it reaches 100%). connection.setRequestProperty("Pulse-Device-Id", PulseController.getInstance().deviceId) pulseLog(TAG, "checkForUpdate: url=${config.updateUrl}") pulseLog(TAG, "checkForUpdate: runtimeVersion=${config.runtimeVersion}") pulseLog(TAG, "checkForUpdate: channel=${config.channel}") pulseLog(TAG, "checkForUpdate: currentUpdateId=$currentUpdateId") config.channel?.let { connection.setRequestProperty("X-Pulse-Channel-Name", it) } currentUpdateId?.let { connection.setRequestProperty("X-Pulse-Current-Update-Id", it) } val responseCode = connection.responseCode pulseLog(TAG, "checkForUpdate: responseCode=$responseCode") if (responseCode == 204) { pulseLog(TAG, "checkForUpdate: no update (204)") callback(Result.success(CheckResult(isAvailable = false))) return } if (responseCode != 200) { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "" } catch (e: Exception) { "" } pulseLogWarn(TAG, "checkForUpdate: server error $responseCode: $errorBody") callback(Result.failure(PulseUpdatesException("Server error: $responseCode"))) return } val response = connection.inputStream.bufferedReader().use { it.readText() } pulseLog(TAG, "checkForUpdate: parsing manifest...") val manifest = ManifestModel.fromJson(response) if (manifest == null) { pulseLogWarn(TAG, "checkForUpdate: failed to parse manifest") callback(Result.failure(PulseUpdatesException("Invalid manifest"))) return } pulseLog(TAG, "checkForUpdate: manifest updateId=${manifest.updateId} runtimeVersion=${manifest.runtimeVersion}") // Check runtime version if (manifest.runtimeVersion != config.runtimeVersion) { pulseLogWarn(TAG, "checkForUpdate: runtime mismatch - manifest=${manifest.runtimeVersion} config=${config.runtimeVersion}") callback(Result.success(CheckResult(isAvailable = false))) return } // Verify signature. When requireSignature is set we MUST have a configured key and // a valid signature (verifyManifestSignature returns false on a missing signature), // so an unsigned/misconfigured release fails closed instead of installing. if (config.requireSignature) { if (config.signingPublicKey.isNullOrEmpty()) { pulseLogError(TAG, "checkForUpdate: requireSignature is set but no signingPublicKey configured; refusing update") callback(Result.failure(PulseUpdatesException("Signature required but no public key configured"))) return } pulseLog(TAG, "checkForUpdate: verifying signature...") if (!verifyManifestSignature(manifest, response, config)) { pulseLogWarn(TAG, "checkForUpdate: signature verification failed") callback(Result.failure(PulseUpdatesException("Signature verification failed"))) return } pulseLog(TAG, "checkForUpdate: signature valid") } else if (config.signingPublicKey != null && manifest.signature != null) { // Optional mode (e.g. DEBUG): verify only WHEN a signature is present. // verifyManifestSignature returns false on a null signature, so verifying // unconditionally here would wrongly reject legitimately-unsigned DEBUG updates. pulseLog(TAG, "checkForUpdate: verifying signature (optional)...") if (!verifyManifestSignature(manifest, response, config)) { pulseLogWarn(TAG, "checkForUpdate: signature verification failed") callback(Result.failure(PulseUpdatesException("Signature verification failed"))) return } pulseLog(TAG, "checkForUpdate: signature valid") } // Check if same update if (manifest.updateId == currentUpdateId) { pulseLog(TAG, "checkForUpdate: same update already installed") callback(Result.success(CheckResult(isAvailable = false))) return } pulseLog(TAG, "checkForUpdate: update available! updateId=${manifest.updateId}") callback(Result.success(CheckResult(isAvailable = true, manifest = manifest))) } catch (e: Exception) { callback(Result.failure(PulseUpdatesException("Network error: ${e.message}"))) } } fun fetchUpdate( config: PulseUpdatesConfig, database: PulseDatabase?, directory: File, cachedManifest: ManifestModel? = null, callback: (Result) -> Unit ) { // If we already have a manifest from a previous check, use it directly if (cachedManifest != null) { pulseLog(TAG, "fetchUpdate: using cached manifest, skipping check") downloadUpdate(cachedManifest, config, database, directory) { downloadResult -> downloadResult.fold( onSuccess = { callback(Result.success(FetchResult(isNew = true, manifest = cachedManifest))) }, onFailure = { callback(Result.failure(it)) } ) } return } // No cached manifest, need to check first checkForUpdate(config, PulseController.getInstance().launchedUpdate?.updateId) { result -> result.fold( onSuccess = { checkResult -> if (!checkResult.isAvailable || checkResult.manifest == null) { callback(Result.success(FetchResult(isNew = false))) return@fold } downloadUpdate(checkResult.manifest, config, database, directory) { downloadResult -> downloadResult.fold( onSuccess = { callback(Result.success(FetchResult(isNew = true, manifest = checkResult.manifest))) }, onFailure = { callback(Result.failure(it)) } ) } }, onFailure = { callback(Result.failure(it)) } ) } } private fun downloadUpdate( manifest: ManifestModel, config: PulseUpdatesConfig, database: PulseDatabase?, directory: File, callback: (Result) -> Unit ) { val updateId = manifest.updateId val bundleHash = manifest.bundle.hash.lowercase() val stagingDir = File(directory, "staging/$updateId") stagingDir.mkdirs() // Store update in database // Convert manifest to JSONObject for storage val manifestJson = try { JSONObject().apply { put("updateId", manifest.updateId) put("runtimeVersion", manifest.runtimeVersion) put("createdAt", manifest.createdAt) put("bundle", JSONObject().apply { put("hash", manifest.bundle.hash) put("url", manifest.bundle.url) put("contentType", manifest.bundle.contentType) }) put("assets", org.json.JSONArray(manifest.assets.map { asset -> JSONObject().apply { asset.key?.let { put("key", it) } put("hash", asset.hash) put("url", asset.url) put("contentType", asset.contentType) asset.name?.let { put("name", it) } asset.type?.let { put("type", it) } asset.scale?.let { put("scale", it) } asset.mainBundleDir?.let { put("mainBundleDir", it) } asset.mainBundleFilename?.let { put("mainBundleFilename", it) } } })) manifest.metadata?.let { meta -> put("metadata", JSONObject(meta)) } } } catch (e: Exception) { pulseLogWarn(TAG, "Failed to convert manifest to JSON: ${e.message}") null } val update = PulseUpdate( updateId = updateId, scopeKey = config.scopeKey, runtimeVersion = manifest.runtimeVersion, commitTime = manifest.commitTime ?: Date(), status = PulseUpdateStatus.DOWNLOADING, manifest = manifestJson, bundleHash = bundleHash, lastAccessed = Date() ) try { database?.addUpdate(update) } catch (e: Exception) { pulseLogWarn(TAG, "Failed to add update: ${e.message}") } // Download bundle (store in assets directory, same as other assets) val bundleDest = File(directory, "assets/sha256/$bundleHash") // Never trust a pre-existing content-addressed file blindly: re-hash it, and if it does not // match (corruption / partial prior write) delete it and re-download. Only skip the download // when the existing file verifies against the manifest hash. var needBundleDownload = true if (bundleDest.exists()) { val existingHash = try { sha256Hex(bundleDest) } catch (e: Exception) { null } if (existingHash == bundleHash) { needBundleDownload = false } else { pulseLogWarn(TAG, "Pre-existing bundle failed re-hash (got=${existingHash?.take(16)} want=${bundleHash.take(16)}), re-downloading") bundleDest.delete() } } if (needBundleDownload) { try { // downloadFile now verifies the SHA-256 on the staging temp BEFORE the atomic move, // so a hash mismatch never produces a file at bundleDest. downloadFile(manifest.bundle.url, bundleDest, stagingDir, bundleHash) } catch (e: Exception) { bundleDest.delete() database?.setStatus(updateId, PulseUpdateStatus.FAILED) stagingDir.deleteRecursively() callback(Result.failure(e)) return } } // Store bundle as launch asset in database try { val bundleAsset = PulseAsset( key = "bundle", hash = bundleHash, type = "application/javascript" ) val assetId = database?.addAsset(bundleAsset) if (assetId != null) { database?.linkAsset(assetId, updateId, "bundle", bundleHash, isLaunchAsset = true) } } catch (e: Exception) { pulseLogWarn(TAG, "Failed to store bundle asset: ${e.message}") } // Download assets and link them to the update val embeddedHashes = PulseController.getInstance().embeddedAssetHashes pulseLog(TAG, "downloadUpdate: embeddedHashes count=${embeddedHashes.size}") if (embeddedHashes.isNotEmpty()) { pulseLog(TAG, "downloadUpdate: first few embedded hashes: ${embeddedHashes.take(3).map { it.take(16) }}") } for (asset in manifest.assets) { val assetHash = asset.hash.lowercase() val assetDest = File(directory, "assets/sha256/$assetHash") // Skip download only if a pre-existing cached file actually re-hashes correctly; // otherwise treat it as missing and (re)download. Never reuse a file without re-hash. if (assetDest.exists()) { val existingHash = try { sha256Hex(assetDest) } catch (e: Exception) { null } if (existingHash != assetHash) { pulseLogWarn(TAG, "Pre-existing asset failed re-hash for ${asset.key}, re-downloading") assetDest.delete() try { downloadFile(asset.url, assetDest, stagingDir, assetHash) } catch (e: Exception) { assetDest.delete() database?.setStatus(updateId, PulseUpdateStatus.FAILED) stagingDir.deleteRecursively() callback(Result.failure(PulseUpdatesException("Asset download failed for ${asset.key}: ${e.message}"))) return } } } // Use embedded fallback at runtime - nothing to download, just link below else if (embeddedHashes.contains(assetHash)) { pulseLog(TAG, "Asset ${assetHash.take(16)}... exists in embedded, skipping download") } // Not present anywhere: download, verifying hash on the staging temp before the move. // ALL-OR-NOTHING: any failure aborts the whole update instead of leaving it READY with a // missing/corrupt asset (matches iOS downloadAssets which fails the batch on first error). else { try { downloadFile(asset.url, assetDest, stagingDir, assetHash) } catch (e: Exception) { assetDest.delete() database?.setStatus(updateId, PulseUpdateStatus.FAILED) stagingDir.deleteRecursively() callback(Result.failure(PulseUpdatesException("Asset download failed for ${asset.key}: ${e.message}"))) return } } // Link asset to update in database (important for buildLocalAssetsForUpdate) try { val dbAsset = PulseAsset( key = asset.key, hash = assetHash, type = asset.contentType, url = asset.url, expectedHash = assetHash, mainBundleDir = asset.mainBundleDir, mainBundleFilename = asset.mainBundleFilename ) val assetId = database?.addAsset(dbAsset) if (assetId != null) { database?.linkAsset(assetId, updateId, asset.key, assetHash, isLaunchAsset = false) } } catch (e: Exception) { pulseLogWarn(TAG, "Failed to link asset to update: ${e.message}") } } // Mark as ready database?.setStatus(updateId, PulseUpdateStatus.READY) stagingDir.deleteRecursively() pulseLog(TAG, "Update $updateId downloaded and ready") callback(Result.success(Unit)) } private fun downloadFile(url: String, destination: File, stagingDir: File, expectedHash: String? = null) { destination.parentFile?.mkdirs() val tempFile = File(stagingDir, "temp_${System.currentTimeMillis()}") try { URL(url).openStream().use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } // Verify SHA-256 on the STAGING TEMP before it ever lands at the content-addressed // destination. A mismatch must never produce a file at `destination`. if (expectedHash != null) { val actualHash = sha256Hex(tempFile) if (actualHash != expectedHash) { throw PulseUpdatesException("Hash mismatch: expected ${expectedHash.take(16)}, got ${actualHash.take(16)}") } } // Atomic move into place. rename can fail across boundaries or if the target exists; // delete any stale target first and treat a failed rename as a hard error so callers // can abort the all-or-nothing apply. destination.delete() if (!tempFile.renameTo(destination)) { throw PulseUpdatesException("Failed to move verified file into place: ${destination.name}") } } finally { // Never leave a partial/temp file behind, regardless of success or failure. if (tempFile.exists()) { tempFile.delete() } } } 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: - Signature Verification private fun verifyManifestSignature( manifest: ManifestModel, manifestJson: String, config: PulseUpdatesConfig ): Boolean { val signature = manifest.signature ?: return false if (signature.alg.lowercase() != "ed25519") return false if (config.signingKeyId != null && signature.keyId != config.signingKeyId) return false val publicKeyBase64 = config.signingPublicKey ?: return false val publicKeyBytes = android.util.Base64.decode(publicKeyBase64, android.util.Base64.DEFAULT) val signatureBytes = android.util.Base64.decode(signature.sig, android.util.Base64.DEFAULT) return try { val canonical = canonicalizeManifestJson(manifestJson) pulseLog(TAG, "verifySignature: canonical length=${canonical.length}") pulseLog(TAG, "verifySignature: canonical start=${canonical.take(200)}...") pulseLog(TAG, "verifySignature: canonical hash=${MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }.take(16)}...") pulseLog(TAG, "verifySignature: signature=${signature.sig.take(20)}...") val spec = net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable.getByName("Ed25519") val pubKeySpec = net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec(publicKeyBytes, spec) val publicKey = net.i2p.crypto.eddsa.EdDSAPublicKey(pubKeySpec) val sgr = net.i2p.crypto.eddsa.EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm)) sgr.initVerify(publicKey) sgr.update(canonical.toByteArray(Charsets.UTF_8)) val result = sgr.verify(signatureBytes) pulseLog(TAG, "verifySignature: result=$result") result } catch (e: Exception) { pulseLogError(TAG, "Signature verification error: ${e.message}") e.printStackTrace() false } } private fun canonicalizeManifestJson(json: String): String { return try { val obj = JSONObject(json) obj.remove("signature") canonicalizeJson(obj) } catch (e: Exception) { json } } private fun canonicalizeJson(value: Any?): String { return when (value) { null, JSONObject.NULL -> "null" is JSONObject -> { val keys = value.keys().asSequence().toList().sorted() val items = keys.mapNotNull { key -> val v = value.opt(key) ?: return@mapNotNull null "\"${escapeJson(key)}\":${canonicalizeJson(v)}" } "{${items.joinToString(",")}}" } is org.json.JSONArray -> { val items = (0 until value.length()).map { canonicalizeJson(value.get(it)) } "[${items.joinToString(",")}]" } is Boolean -> if (value) "true" else "false" is Number -> value.toString() is String -> "\"${escapeJson(value)}\"" else -> "null" } } private fun escapeJson(input: String): String { val sb = StringBuilder() for (char in input) { when (char) { '"' -> sb.append("\\\"") '\\' -> sb.append("\\\\") '\n' -> sb.append("\\n") '\r' -> sb.append("\\r") '\t' -> sb.append("\\t") else -> { if (char.code < 32) { sb.append(String.format("\\u%04x", char.code)) } else { sb.append(char) } } } } return sb.toString() } }