custom pwa and shortcut support

This commit is contained in:
Fabian Freund
2026-03-26 10:38:36 +01:00
parent b0c9d6c283
commit fe07b182ec
30 changed files with 896 additions and 69 deletions
@@ -309,7 +309,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url ->
components.core.webAppManifestStorage.getManifestCache(url)
}
} ?: customTab.content.webAppManifest
windowFeature.set(
feature = CustomTabWindowFeature(activity, store, sessionId),
@@ -15,6 +15,11 @@ object PwaConstants {
const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id"
const val EXTRA_PWA_TOKEN = "pwa_token"
const val EXTRA_PWA_INSTALL_START_URL = "pwa_install_start_url"
const val EXTRA_SHORTCUT_TYPE = "shortcut_type"
// Shortcut type values
const val SHORTCUT_TYPE_BASIC = "basic"
const val SHORTCUT_TYPE_PWA = "pwa"
// Profile and file paths
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
@@ -89,8 +89,16 @@ class IntentReceiverActivity : Activity() {
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)
val shortcutType = intent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE)
if (profileUuid != null) {
if (isTrustedPwaLaunch(intent, profileUuid, token)) {
// Basic shortcuts open in the regular browser, not as standalone PWA
if (shortcutType == PwaConstants.SHORTCUT_TYPE_BASIC) {
Log.d(TAG, "Trusted basic shortcut, routing to regular browser: ${intent.dataString}")
handleBasicShortcutIntent(intent, profileUuid)
return
}
Log.d(TAG, "Trusted PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId")
handlePwaIntent(intent, profileUuid, contextId)
return
@@ -273,7 +281,11 @@ class IntentReceiverActivity : Activity() {
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
Log.d(TAG, "Profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
showProfileMismatchDialog(url, contextId)
showProfileMismatchDialog(
intent = intent,
onProceed = { launchPwaWithContext(url, contextId) },
isPwa = true,
)
} else {
Log.d(TAG, "Profile match or indeterminate, launching PWA with contextId=$contextId")
launchPwaWithContext(url, contextId)
@@ -300,26 +312,48 @@ class IntentReceiverActivity : Activity() {
}
/**
* Shows a dialog when the current profile doesn't match the PWA's installation profile.
* Handles basic shortcut intents with profile validation.
* Checks profile match and shows dialog if different, then forwards to regular browser.
*/
private fun handleBasicShortcutIntent(intent: Intent, profileUuid: String) {
val currentProfileUuid = getCurrentProfileUuid()
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
Log.d(TAG, "Basic shortcut profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
showProfileMismatchDialog(
intent = intent,
onProceed = { handleRegularIntent(it) },
isPwa = false,
)
} else {
Log.d(TAG, "Basic shortcut profile match or indeterminate, routing to browser")
handleRegularIntent(intent)
}
}
/**
* Shows a dialog when the current profile doesn't match the shortcut's installation profile.
*/
private fun showProfileMismatchDialog(
url: String,
contextId: String?,
intent: Intent,
onProceed: (Intent) -> Unit,
isPwa: Boolean,
) {
val message = "This PWA was originally installed in a different profile. " +
val typeLabel = if (isPwa) "PWA" else "shortcut"
val message = "This $typeLabel was originally installed in a different profile. " +
"Opening it here uses only your current profile's data and settings. " +
"The original profile's app state and saved data will not be used.\n\n" +
"Do you want to proceed anyway?"
AlertDialog.Builder(this)
.setTitle("PWA Profile Mismatch")
.setTitle("Profile Mismatch")
.setMessage(message)
.setPositiveButton("Open in Current Profile") { _, _ ->
Log.d(TAG, "User chose to open PWA despite profile mismatch")
launchPwaWithContext(url, contextId)
Log.d(TAG, "User chose to open $typeLabel despite profile mismatch")
onProceed(intent)
}
.setNegativeButton("Cancel") { _, _ ->
Log.d(TAG, "User cancelled PWA launch due to profile mismatch")
Log.d(TAG, "User cancelled $typeLabel launch due to profile mismatch")
finish()
}
.setOnCancelListener {
@@ -86,11 +86,17 @@ class GeckoPwaApiImpl(
return@launch
}
val manifest = tab.content.webAppManifest
if (manifest == null) {
logger.warn("No manifest found for tab ${tab.id}")
callback(Result.success(false))
return@launch
val manifest = tab.content.webAppManifest ?: run {
// Generate a synthetic manifest for sites without one
val url = tab.content.url
val title = tab.content.title.ifBlank { url }
logger.debug("Generating synthetic manifest for tab ${tab.id}: $url")
WebAppManifest(
name = title,
startUrl = url,
display = WebAppManifest.DisplayMode.STANDALONE,
scope = extractScope(url),
)
}
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
@@ -161,6 +167,7 @@ class GeckoPwaApiImpl(
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, manifest.startUrl)
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_PWA)
}
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
@@ -179,6 +186,11 @@ class GeckoPwaApiImpl(
}
}.build()
// Update existing shortcut intent if one exists with the same ID
// (e.g. upgrading a basic shortcut to PWA). requestPinShortcut alone
// may reuse the cached intent on some launchers.
updateExistingShortcut(shortcutManager, shortcut)
val success = shortcutManager.requestPinShortcut(shortcut, null)
logger.debug("PWA shortcut creation result: $success")
success
@@ -188,6 +200,23 @@ class GeckoPwaApiImpl(
}
}
/**
* Updates an existing pinned/cached shortcut's intent and metadata.
* This is necessary because requestPinShortcut may reuse the old cached intent
* on some launchers instead of the new ShortcutInfo's intent.
*/
private fun updateExistingShortcut(shortcutManager: ShortcutManager, shortcut: ShortcutInfo) {
try {
val existingIds = shortcutManager.pinnedShortcuts.map { it.id }.toSet()
if (shortcut.id in existingIds) {
shortcutManager.updateShortcuts(listOf(shortcut))
logger.debug("Updated existing pinned shortcut: ${shortcut.id}")
}
} catch (e: Exception) {
logger.debug("Could not update existing shortcut (may not exist): ${e.message}")
}
}
/**
* Generates a collision-resistant shortcut ID from URL + profile using SHA-256.
*/
@@ -239,6 +268,158 @@ class GeckoPwaApiImpl(
}
}
override fun installBasicShortcut(
tabId: String?,
profileUuid: String,
contextId: String?,
overrideShortcutName: String?,
callback: (Result<Boolean>) -> Unit
) {
logger.debug("installBasicShortcut called for tabId: $tabId, profileUuid: $profileUuid")
coroutineScope.launch {
try {
val store = components.core.store
val tab = if (tabId != null) {
store.state.findTab(tabId)
} else {
store.state.selectedTab
}
if (tab == null) {
logger.warn("Tab not found for installBasicShortcut: $tabId")
callback(Result.success(false))
return@launch
}
val success = createBasicShortcut(
url = tab.content.url,
title = overrideShortcutName ?: tab.content.title,
tabIcon = tab.content.icon,
profileUuid = profileUuid,
contextId = contextId,
)
callback(Result.success(success))
} catch (e: Exception) {
logger.error("Failed to create basic shortcut", e)
callback(Result.failure(e))
}
}
}
/**
* Creates a basic bookmark-style shortcut that opens in a regular browser tab.
* Does not require or store a manifest.
*/
private suspend fun createBasicShortcut(
url: String,
title: String,
tabIcon: Bitmap?,
profileUuid: String,
contextId: String?,
): Boolean = withContext(Dispatchers.Main) {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
logger.warn("Pinned shortcuts require Android O or later")
return@withContext false
}
val shortcutManager = context.getSystemService<ShortcutManager>()
?: run {
logger.error("ShortcutManager not available")
return@withContext false
}
if (!shortcutManager.isRequestPinShortcutSupported) {
logger.warn("Pinning shortcuts is not supported")
return@withContext false
}
val shortcutId = generateShortcutId(url, profileUuid)
val launchToken = resolveLaunchToken(
shortcutManager = shortcutManager,
shortcutId = shortcutId,
startUrl = url,
profileUuid = profileUuid,
)
val shortLabel = title.ifBlank { url }
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse(url)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid)
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, url)
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_BASIC)
}
val icon = loadTabIcon(url, tabIcon)
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
setShortLabel(shortLabel)
setLongLabel(shortLabel)
setIntent(shortcutIntent)
icon?.let { setIcon(it) }
}.build()
// Update existing shortcut intent if one exists with the same ID
updateExistingShortcut(shortcutManager, shortcut)
val success = shortcutManager.requestPinShortcut(shortcut, null)
logger.debug("Basic shortcut creation result: $success")
success
} catch (e: Exception) {
logger.error("Failed to create basic shortcut", e)
false
}
}
/**
* Loads an icon for the shortcut from the tab's favicon or BrowserIcons.
*/
private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? = withContext(Dispatchers.IO) {
try {
// Try using the tab's existing favicon first
val bitmap = tabIcon?.takeUnless { it.isRecycled }
?: run {
// Fall back to loading via BrowserIcons
val iconRequest = IconRequest(
url = url,
size = IconRequest.Size.LAUNCHER,
)
components.core.icons.loadIcon(iconRequest).await()?.bitmap
}
bitmap?.takeUnless { it.isRecycled }?.let {
val bitmapCopy = it.copy(it.config ?: Bitmap.Config.ARGB_8888, false)
Icon.createWithBitmap(bitmapCopy)
}
} catch (e: Exception) {
logger.error("Failed to load tab icon", e)
null
}
}
/**
* Extracts the scope from a URL (origin + path up to last segment).
*/
private fun extractScope(url: String): String {
return try {
val uri = Uri.parse(url)
val path = uri.path ?: "/"
val scopePath = if (path.contains("/")) {
path.substringBeforeLast("/") + "/"
} else {
"/"
}
uri.buildUpon().path(scopePath).clearQuery().fragment(null).build().toString()
} catch (e: Exception) {
url
}
}
override fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit) {
logger.debug("getInstalledWebApps called")
coroutineScope.launch {
@@ -8943,6 +8943,20 @@ interface GeckoPwaApi {
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
/** Returns a list of all installed PWA manifests. */
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
/**
* Creates a basic bookmark shortcut on the home screen (no manifest required).
*
* Unlike [installWebApp], this creates a simple shortcut that opens
* in a regular browser tab rather than standalone PWA mode.
* Uses the page title and favicon for the shortcut.
*
* The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab.
* The [profileUuid] is the UUID of the current user profile.
* The [contextId] is the container's contextual identity (optional).
* The [overrideShortcutName] allows customizing the shortcut label.
* Returns true if the shortcut was created successfully.
*/
fun installBasicShortcut(tabId: String?, profileUuid: String, contextId: String?, overrideShortcutName: String?, callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by GeckoPwaApi. */
@@ -8993,6 +9007,29 @@ interface GeckoPwaApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installBasicShortcut$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val tabIdArg = args[0] as String?
val profileUuidArg = args[1] as String
val contextIdArg = args[2] as String?
val overrideShortcutNameArg = args[3] as String?
api.installBasicShortcut(tabIdArg, profileUuidArg, contextIdArg, overrideShortcutNameArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}