diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 7355dd34..5e955e3a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -152,7 +152,8 @@ object GlobalComponents { newComponents.core.engine.warmUp() - if (previousCustomTabs.isNotEmpty()) { + fun restorePreviousCustomTabs() { + if (previousCustomTabs.isEmpty()) return for (tab in previousCustomTabs) { val existing = newComponents.core.store.state.findCustomTab(tab.id) if (existing == null) { @@ -164,7 +165,14 @@ object GlobalComponents { } if (mode == ComponentsMode.FULL) { - restoreBrowserState(newComponents) + val restoreJob = restoreBrowserState(newComponents) + if (previousCustomTabs.isNotEmpty()) { + restoreJob.invokeOnCompletion { + GlobalScope.launch(Dispatchers.Main) { + restorePreviousCustomTabs() + } + } + } restoreDownloads(newComponents) try { @@ -205,6 +213,8 @@ object GlobalComponents { GlobalScope.launch(Dispatchers.IO) { newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory() } + } else { + restorePreviousCustomTabs() } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt index 40076597..a910960e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/PwaConstants.kt @@ -13,10 +13,12 @@ object PwaConstants { // Intent extras keys for PWA metadata const val EXTRA_PWA_PROFILE_UUID = "pwa_profile_uuid" const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id" + const val EXTRA_PWA_TOKEN = "pwa_token" // Profile and file paths const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile" const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping" + const val PROFILE_MAPPING_TOKEN_PREFIX = "token_" // Component initialization timeouts const val COMPONENT_INIT_TIMEOUT_MS = 10000L diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt index 0ae6604a..93a8db4d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt @@ -9,6 +9,7 @@ package eu.weblibre.flutter_mozilla_components.activities import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment @@ -33,6 +34,24 @@ import mozilla.components.support.base.log.logger.Logger * Uses an empty taskAffinity so Custom Tabs appear as a separate task from the main app. */ class ExternalAppBrowserActivity : AppCompatActivity() { + companion object { + private const val TAG = "ExternalAppBrowserActivity" + + const val EXTRA_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id" + const val EXTRA_WEB_APP_MANIFEST_URL = "web_app_manifest_url" + + fun createIntent( + context: Context, + customTabSessionId: String, + webAppManifestUrl: String? = null, + ): Intent { + return Intent(context, ExternalAppBrowserActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId) + webAppManifestUrl?.let { putExtra(EXTRA_WEB_APP_MANIFEST_URL, it) } + } + } + } private val logger = Logger("ExternalAppBrowserActivity") private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -48,8 +67,9 @@ class ExternalAppBrowserActivity : AppCompatActivity() { val sessionId = customTabSessionId if (sessionId == null) { + Log.e(TAG, "No custom tab session ID provided") logger.error("No custom tab session ID provided, finishing.") - finish() + fallbackToMainActivity() return } @@ -87,8 +107,9 @@ class ExternalAppBrowserActivity : AppCompatActivity() { // Timeout reached if (isActive) { + Log.e(TAG, "Timeout waiting for components") logger.error("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms") - finish() + fallbackToMainActivity() } } } @@ -102,8 +123,9 @@ class ExternalAppBrowserActivity : AppCompatActivity() { // Verify session exists if (components.core.store.state.findCustomTab(sessionId) == null) { + Log.e(TAG, "Custom tab session $sessionId not found in store") logger.error("Custom tab session $sessionId not found in store, finishing.") - finish() + fallbackToMainActivity() return } @@ -124,11 +146,25 @@ class ExternalAppBrowserActivity : AppCompatActivity() { val sessionId = customTabSessionId ?: return val components = GlobalComponents.components ?: return if (components.core.store.state.findCustomTab(sessionId) == null) { + Log.w(TAG, "Custom tab session $sessionId gone on resume") logger.debug("Custom tab session $sessionId gone, finishing activity.") - finish() + fallbackToMainActivity() } } + private fun fallbackToMainActivity() { + val mainIntent = Intent().apply { + setClassName(this@ExternalAppBrowserActivity, "eu.weblibre.gecko.MainActivity") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + webAppManifestUrl?.let { + action = Intent.ACTION_VIEW + data = android.net.Uri.parse(it) + } + } + startActivity(mainIntent) + finish() + } + override fun onDestroy() { super.onDestroy() @@ -168,20 +204,4 @@ class ExternalAppBrowserActivity : AppCompatActivity() { } } - companion object { - const val EXTRA_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id" - const val EXTRA_WEB_APP_MANIFEST_URL = "web_app_manifest_url" - - fun createIntent( - context: Context, - customTabSessionId: String, - webAppManifestUrl: String? = null, - ): Intent { - return Intent(context, ExternalAppBrowserActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK - putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId) - webAppManifestUrl?.let { putExtra(EXTRA_WEB_APP_MANIFEST_URL, it) } - } - } - } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt index b312c18e..c7f879f8 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt @@ -8,22 +8,23 @@ package eu.weblibre.flutter_mozilla_components.activities import android.app.Activity import android.app.AlertDialog +import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log +import eu.weblibre.flutter_mozilla_components.Components import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.PwaConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.components.feature.customtabs.CustomTabIntentProcessor import mozilla.components.feature.intent.ext.getSessionId import mozilla.components.feature.pwa.intent.WebAppIntentProcessor -import mozilla.components.support.base.log.logger.Logger +import mozilla.components.browser.state.state.ExternalAppType import java.io.File /** @@ -37,17 +38,18 @@ import java.io.File * if the current profile differs from the installation profile. */ class IntentReceiverActivity : Activity() { + companion object { + private const val TAG = "IntentReceiverActivity" + } - private val logger = Logger("IntentReceiverActivity") private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - private var pendingIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val intent = intent?.let { Intent(it) } ?: Intent() - logger.debug("onCreate: action=${intent.action} data=${intent.dataString}") + Log.d(TAG, "onCreate: action=${intent.action} data=${intent.dataString}") // Strip flags that could interfere with task management intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv() @@ -68,9 +70,8 @@ class IntentReceiverActivity : Activity() { return } - logger.warn("Components not initialized, waiting for initialization...") - pendingIntent = intent - waitForComponentsWithTimeout() + Log.w(TAG, "Components not initialized, routing directly to MainActivity") + handleRegularIntent(intent) return } @@ -81,16 +82,21 @@ class IntentReceiverActivity : Activity() { // Check if this is our custom PWA intent with profile metadata val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID) + val token = intent.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN) if (profileUuid != null) { - logger.debug("PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId") - handlePwaIntent(intent, profileUuid, contextId) - return + if (isTrustedPwaLaunch(intent, profileUuid, token)) { + Log.d(TAG, "Trusted PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId") + handlePwaIntent(intent, profileUuid, contextId) + return + } + + Log.w(TAG, "Ignoring untrusted PWA profile metadata on VIEW intent") } // Fall back to standard intent processors for Custom Tabs and legacy PWAs val components = GlobalComponents.components ?: run { - logger.error("Components became null during routing") + Log.e(TAG, "Components became null during routing") handleRegularIntent(intent) return } @@ -110,13 +116,14 @@ class IntentReceiverActivity : Activity() { ) for ((name, processor) in processors) { - logger.debug("Trying $name processor...") + Log.d(TAG, "Trying $name processor...") try { val result = processor.process(intent) - logger.debug("$name processor result: $result") + Log.d(TAG, "$name processor result: $result") if (result) { val sessionId = intent.getSessionId() - logger.debug("$name session ID from intent: $sessionId") + ?: resolveSessionIdFromStore(name, intent, components) + Log.d(TAG, "$name session ID from intent: $sessionId") if (sessionId != null) { val externalIntent = ExternalAppBrowserActivity.createIntent( context = this, @@ -127,18 +134,79 @@ class IntentReceiverActivity : Activity() { finish() return } else { - logger.warn("$name processor succeeded but no session ID in intent!") + Log.w(TAG, "$name processor succeeded but no session ID in intent!") } } } catch (e: Exception) { - logger.error("Error in $name processor", e) + Log.e(TAG, "Error in $name processor", e) } } - logger.debug("No processor matched, routing to MainActivity") + Log.d(TAG, "No processor matched, routing to MainActivity") handleRegularIntent(intent) } + private fun isTrustedPwaLaunch(intent: Intent, profileUuid: String, token: String?): Boolean { + val url = intent.dataString ?: return false + val action = intent.action + val hasTrustedAction = action == Intent.ACTION_VIEW || action == "mozilla.components.feature.pwa.VIEW_PWA" + if (!hasTrustedAction || token.isNullOrEmpty()) { + return false + } + + val prefs = applicationContext.getSharedPreferences( + PwaConstants.PROFILE_MAPPING_PREFS, + Context.MODE_PRIVATE, + ) + val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${url}::${profileUuid}" + val storedToken = prefs.getString(tokenKey, null) + if (storedToken == null || storedToken != token) { + Log.w(TAG, "PWA token mismatch for $url") + return false + } + + return true + } + + private fun resolveSessionIdFromStore( + processorName: String, + intent: Intent, + components: Components, + ): String? { + val customTabs = components.core.store.state.customTabs + if (customTabs.isEmpty()) { + return null + } + + return when (processorName) { + "PWA" -> { + val targetUrl = intent.dataString + val pwaTab = customTabs.lastOrNull { tab -> + val appType = tab.config.externalAppType + val isPwa = appType == ExternalAppType.PROGRESSIVE_WEB_APP || + appType == ExternalAppType.TRUSTED_WEB_ACTIVITY + if (!isPwa) { + return@lastOrNull false + } + + if (targetUrl == null) { + true + } else { + tab.content.url == targetUrl || tab.content.webAppManifest?.startUrl == targetUrl + } + } ?: customTabs.lastOrNull { tab -> + val appType = tab.config.externalAppType + appType == ExternalAppType.PROGRESSIVE_WEB_APP || + appType == ExternalAppType.TRUSTED_WEB_ACTIVITY + } + + pwaTab?.id + } + + else -> customTabs.lastOrNull()?.id + } + } + /** * Handles PWA intents with profile and context metadata. * Checks if current profile matches and shows dialog if different. @@ -150,7 +218,7 @@ class IntentReceiverActivity : Activity() { ) { val url = intent.dataString if (url == null) { - logger.error("PWA intent has no URL") + Log.e(TAG, "PWA intent has no URL") handleRegularIntent(intent) return } @@ -158,10 +226,10 @@ class IntentReceiverActivity : Activity() { val currentProfileUuid = getCurrentProfileUuid() if (currentProfileUuid != null && currentProfileUuid != profileUuid) { - logger.debug("Profile mismatch: current=$currentProfileUuid, expected=$profileUuid") + Log.d(TAG, "Profile mismatch: current=$currentProfileUuid, expected=$profileUuid") showProfileMismatchDialog(url, contextId) } else { - logger.debug("Profile match or indeterminate, launching PWA with contextId=$contextId") + Log.d(TAG, "Profile match or indeterminate, launching PWA with contextId=$contextId") launchPwaWithContext(url, contextId) } } @@ -180,7 +248,7 @@ class IntentReceiverActivity : Activity() { null } } catch (e: Exception) { - logger.error("Failed to read current profile UUID", e) + Log.e(TAG, "Failed to read current profile UUID", e) null } } @@ -202,11 +270,11 @@ class IntentReceiverActivity : Activity() { .setTitle("PWA Profile Mismatch") .setMessage(message) .setPositiveButton("Open Anyway") { _, _ -> - logger.debug("User chose to open PWA despite profile mismatch") + Log.d(TAG, "User chose to open PWA despite profile mismatch") launchPwaWithContext(url, contextId) } .setNegativeButton("Cancel") { _, _ -> - logger.debug("User cancelled PWA launch due to profile mismatch") + Log.d(TAG, "User cancelled PWA launch due to profile mismatch") finish() } .setOnCancelListener { @@ -221,7 +289,7 @@ class IntentReceiverActivity : Activity() { private fun launchPwaWithContext(url: String, contextId: String?) { val components = GlobalComponents.components ?: run { - logger.error("Components not available for PWA launch") + Log.e(TAG, "Components not available for PWA launch") handleRegularIntent(intent) return } @@ -238,7 +306,7 @@ class IntentReceiverActivity : Activity() { manifest = manifest ) - logger.debug("Created PWA session: contextId=$contextId, sessionId=$sessionId") + Log.d(TAG, "Created PWA session: contextId=$contextId, sessionId=$sessionId") val externalIntent = ExternalAppBrowserActivity.createIntent( context = this@IntentReceiverActivity, @@ -248,7 +316,7 @@ class IntentReceiverActivity : Activity() { startActivity(externalIntent) finish() } catch (e: Exception) { - logger.error("Failed to launch PWA with context", e) + Log.e(TAG, "Failed to launch PWA with context", e) handleRegularIntent(intent) } } @@ -288,37 +356,6 @@ class IntentReceiverActivity : Activity() { return tab.id } - /** - * Waits for GlobalComponents to be initialized with a timeout. - * Once components are ready, shows branded loading screen before routing. - * Falls back to MainActivity if timeout is reached (10 seconds). - */ - private fun waitForComponentsWithTimeout() { - coroutineScope.launch { - var elapsedMs = 0L - - while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) { - if (GlobalComponents.components != null) { - logger.debug("Components initialized after ${elapsedMs}ms") - pendingIntent?.let { routeIntent(it) } - pendingIntent = null - return@launch - } - - delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS) - elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS - } - - if (isActive) { - logger.warn("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms, falling back to MainActivity") - pendingIntent?.let { intent -> - handleRegularIntent(intent) - } - pendingIntent = null - } - } - } - private fun handleRegularIntent(intent: Intent) { val mainActivityIntent = Intent(intent).apply { setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity") diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt index 24cec765..5805c1c4 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoPwaApiImpl.kt @@ -37,6 +37,7 @@ import mozilla.components.concept.engine.manifest.WebAppManifest import mozilla.components.support.base.log.logger.Logger import java.io.File import java.security.MessageDigest +import java.util.UUID /** * Implementation of GeckoPwaApi that provides PWA install and query functionality. @@ -52,6 +53,12 @@ class GeckoPwaApiImpl( } private val logger = Logger("GeckoPwaApiImpl") + private val appPrefs by lazy { + context.applicationContext.getSharedPreferences( + PwaConstants.PROFILE_MAPPING_PREFS, + Context.MODE_PRIVATE, + ) + } private val components by lazy { requireNotNull(GlobalComponents.components) { "Components not initialized" } @@ -138,6 +145,7 @@ class GeckoPwaApiImpl( val (iconBitmap, isMaskable) = loadPwaIcon(manifest) val shortcutId = generateShortcutId(manifest.startUrl) + val launchToken = generateAndStoreLaunchToken(manifest.startUrl, profileUuid) val appName = manifest.shortName ?: manifest.name ?: "Web App" @@ -146,6 +154,7 @@ class GeckoPwaApiImpl( data = Uri.parse(manifest.startUrl) putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid) putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId) + putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken) } val shortcut = ShortcutInfo.Builder(context, shortcutId).apply { @@ -247,15 +256,25 @@ class GeckoPwaApiImpl( } private fun storeProfileMapping(startUrl: String, profileUuid: String) { - context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE) - .edit() + appPrefs.edit() .putString(startUrl, profileUuid) .apply() } + private fun generateAndStoreLaunchToken(startUrl: String, profileUuid: String): String { + val token = UUID.randomUUID().toString() + val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}" + val committed = appPrefs.edit() + .putString(tokenKey, token) + .commit() + if (!committed) { + logger.warn("Failed to persist PWA launch token for $startUrl") + } + return token + } + private fun getProfileMapping(startUrl: String): String? { - return context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE) - .getString(startUrl, null) + return appPrefs.getString(startUrl, null) } private fun getCurrentProfileUuid(): String? {