custom pwa and shortcut support
This commit is contained in:
+1
-1
@@ -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),
|
||||
|
||||
+5
@@ -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"
|
||||
|
||||
+43
-9
@@ -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 {
|
||||
|
||||
+186
-5
@@ -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 {
|
||||
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user