add token based security; improve stability;

This commit is contained in:
Fabian Freund
2026-02-11 07:24:27 +01:00
parent 5cdeee6526
commit 8e25814a67
5 changed files with 173 additions and 85 deletions
@@ -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()
}
}
@@ -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
@@ -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) }
}
}
}
}
@@ -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")
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")
@@ -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? {