diff --git a/README.md b/README.md index 4683cf1f..3b537cbd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,47 @@ This SDK does not transfer any information over the network. Web browsing inform stored if the WebView fallback is enabled. The permission to read the location can be managed via the usual Android settings. +## Using Shortcuts in Trusted Web Activities + +When implementing shortcuts (e.g. from `shortcuts.xml`) in a Trusted Web Activity (TWA) application, launching the TWA through `LauncherActivity` on Android Desktop (such as ChromeOS) can result in unresponsive windows due to window manager interactions with translucent activities. + +To prevent this issue, you should use the dedicated `ShortcutTrampolineActivity` for all your app's shortcut intents. + +### 1. Create a `shortcuts.xml` resource + +Create `res/xml/shortcuts.xml` and target `ShortcutTrampolineActivity` as the `targetClass`, passing the shortcut target URL in the `android:data` field: + +```xml + + + + + + +``` + +### 2. Reference the shortcuts in your Launcher Activity + +In your `AndroidManifest.xml`, reference `shortcuts.xml` within the `` tag of your main launcher activity: + +```xml + + + ... + +``` + +`ShortcutTrampolineActivity` runs with `Theme.NoDisplay` and will process the shortcut launch securely by validating the URL against your configured TWA domains, routing the launch asynchronously using the application context, and closing itself instantly before any window transitions are impacted. + ## Source Code Headers Every file containing source code must include copyright and license diff --git a/androidbrowserhelper/src/main/AndroidManifest.xml b/androidbrowserhelper/src/main/AndroidManifest.xml index d84dce44..305e79ce 100644 --- a/androidbrowserhelper/src/main/AndroidManifest.xml +++ b/androidbrowserhelper/src/main/AndroidManifest.xml @@ -23,4 +23,11 @@ + + + + diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivity.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivity.java index ec3bd069..58e98604 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivity.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivity.java @@ -209,39 +209,16 @@ protected void launchTwa() { return; } - CustomTabColorSchemeParams defaultColorScheme = new CustomTabColorSchemeParams.Builder() - .setNavigationBarColor(getColorCompat(mMetadata.navigationBarColorId)) - .setNavigationBarDividerColor(getColorCompat(mMetadata.navigationBarDividerColorId)) - .setToolbarColor(getColorCompat(mMetadata.statusBarColorId)) - .build(); - CustomTabColorSchemeParams darkModeColorScheme = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(getColorCompat(mMetadata.statusBarColorDarkId)) - .setNavigationBarColor(getColorCompat(mMetadata.navigationBarColorDarkId)) - .setNavigationBarDividerColor( - getColorCompat(mMetadata.navigationBarDividerColorDarkId)) - .build(); - Uri launchUrl = getLaunchingUrl(); - TrustedWebActivityIntentBuilder twaBuilder = - new TrustedWebActivityIntentBuilder(launchUrl) - .setDefaultColorSchemeParams(defaultColorScheme) - .setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM) - .setColorSchemeParams( - CustomTabsIntent.COLOR_SCHEME_DARK, darkModeColorScheme) - .setDisplayMode(getDisplayMode()) - .setDisplayOverrideList(mMetadata.displayOverrideList) - .setScreenOrientation(mMetadata.screenOrientation) - .setLaunchHandlerClientMode(mMetadata.launchHandlerClientMode); + TrustedWebActivityIntentBuilder twaBuilder = new TrustedWebActivityIntentBuilder(launchUrl); + mMetadata.configureIntentBuilder(twaBuilder, this); + twaBuilder.setDisplayMode(getDisplayMode()); Uri intentUrl = getUrlForIntent(getIntent()); if (!launchUrl.equals(intentUrl) && intentUrl != null) { twaBuilder.setOriginalLaunchUrl(intentUrl); } - if (mMetadata.additionalTrustedOrigins != null) { - twaBuilder.setAdditionalTrustedOrigins(mMetadata.additionalTrustedOrigins); - } - addShareDataIfPresent(twaBuilder); addFileDataIfPresent(twaBuilder); diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java index 4280341f..46929863 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java @@ -17,16 +17,23 @@ import android.app.Activity; import android.content.ComponentName; import android.content.Context; +import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.content.res.Resources; +import android.content.pm.PackageInfo; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabColorSchemeParams; +import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.trusted.LaunchHandlerClientMode; import androidx.browser.trusted.ScreenOrientation; import androidx.browser.trusted.TrustedWebActivityDisplayMode; +import androidx.browser.trusted.TrustedWebActivityIntentBuilder; +import androidx.core.content.ContextCompat; import java.util.ArrayList; import java.util.Arrays; @@ -342,6 +349,44 @@ private static List getDisplayOverride(@NonNull B return clientMode != null ? clientMode : LaunchHandlerClientMode.AUTO; } + /** + * Configures the TrustedWebActivityIntentBuilder with TWA metadata parameters. + */ + public void configureIntentBuilder(TrustedWebActivityIntentBuilder builder, Context context) { + CustomTabColorSchemeParams defaultColorScheme = new CustomTabColorSchemeParams.Builder() + .setNavigationBarColor(ContextCompat.getColor(context, navigationBarColorId)) + .setNavigationBarDividerColor(ContextCompat.getColor(context, navigationBarDividerColorId)) + .setToolbarColor(ContextCompat.getColor(context, statusBarColorId)) + .build(); + CustomTabColorSchemeParams darkModeColorScheme = new CustomTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, statusBarColorDarkId)) + .setNavigationBarColor(ContextCompat.getColor(context, navigationBarColorDarkId)) + .setNavigationBarDividerColor( + ContextCompat.getColor(context, navigationBarDividerColorDarkId)) + .build(); + + builder.setDefaultColorSchemeParams(defaultColorScheme) + .setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM) + .setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, darkModeColorScheme) + .setDisplayMode(displayMode) + .setDisplayOverrideList(displayOverrideList) + .setScreenOrientation(screenOrientation) + .setLaunchHandlerClientMode(launchHandlerClientMode); + + if (additionalTrustedOrigins != null) { + builder.setAdditionalTrustedOrigins(additionalTrustedOrigins); + } + } + + private static boolean tryMergeMetadata(Bundle target, @Nullable ActivityInfo activityInfo) { + if (activityInfo != null && activityInfo.metaData != null + && activityInfo.metaData.containsKey(METADATA_DEFAULT_URL)) { + target.putAll(activityInfo.metaData); + return true; + } + return false; + } + /** * Creates LauncherActivityMetadata instance based on metadata of the passed Activity. */ @@ -371,6 +416,40 @@ public static LauncherActivityMetadata parse(Context context) { // Will only happen if the package provided (the one we are running in) is not // installed - so should never happen. } + + if (!metaData.containsKey(METADATA_DEFAULT_URL)) { + try { + PackageManager pm = context.getPackageManager(); + + // 1. Query launcher activities/aliases (handles metadata on alias itself) + Intent queryIntent = new Intent(Intent.ACTION_MAIN); + queryIntent.addCategory(Intent.CATEGORY_LAUNCHER); + queryIntent.setPackage(context.getPackageName()); + List resolveInfos = pm.queryIntentActivities( + queryIntent, PackageManager.GET_META_DATA); + for (ResolveInfo resolveInfo : resolveInfos) { + if (tryMergeMetadata(metaData, resolveInfo.activityInfo)) { + return new LauncherActivityMetadata(metaData, resources); + } + } + + // 2. Fall back to scanning all package activities if METADATA_DEFAULT_URL is + // not inside the metadata. + // (handles metadata on target activity when launcher filter is on an alias) + PackageInfo packageInfo = pm.getPackageInfo(context.getPackageName(), + PackageManager.GET_ACTIVITIES | PackageManager.GET_META_DATA); + if (packageInfo.activities != null) { + for (ActivityInfo activityInfo : packageInfo.activities) { + if (tryMergeMetadata(metaData, activityInfo)) { + return new LauncherActivityMetadata(metaData, resources); + } + } + } + } catch (PackageManager.NameNotFoundException e) { + // Ignore. + } + } + return new LauncherActivityMetadata(metaData, resources); } } diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/NotificationUtils.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/NotificationUtils.java index 550720ea..df2c8ab1 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/NotificationUtils.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/NotificationUtils.java @@ -23,7 +23,6 @@ import android.content.pm.ServiceInfo; import android.os.Build; import android.os.Bundle; -import androidx.annotation.VisibleForTesting; import androidx.core.app.NotificationManagerCompat; import java.util.Locale; @@ -55,7 +54,6 @@ public static boolean areNotificationsEnabled(Context context, String channelNam /** * Checks if high-priority notifications are configured in the manifest metadata. */ - @VisibleForTesting static boolean shouldUseHighPriorityNotifications(Context context) { if (!(context instanceof Service)) return false; try { @@ -137,7 +135,6 @@ static void createNotificationChannelAndMaybeDeleteOldOne(Context context, Strin * Generates a notification channel id from a channel name. * TODO: Remove this when we can use the method defined in AndroidX instead. */ - @VisibleForTesting static String channelNameToId(Context context, String name) { String baseId = name.toLowerCase(Locale.ROOT).replace(' ', '_'); return shouldUseHighPriorityNotifications(context) diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/ShortcutTrampolineActivity.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/ShortcutTrampolineActivity.java new file mode 100644 index 00000000..13925e41 --- /dev/null +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/ShortcutTrampolineActivity.java @@ -0,0 +1,187 @@ +// Copyright 2026 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidbrowserhelper.trusted; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.Nullable; +import androidx.browser.trusted.TrustedWebActivityIntent; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.browser.customtabs.CustomTabsClient; +import androidx.browser.customtabs.TrustedWebUtils; +import androidx.browser.trusted.TrustedWebActivityIntentBuilder; + +/** + * A trampoline activity that handles Trusted Web Activity shortcuts. + * It is defined as a noDisplay activity, meaning it finishes in onCreate() + * before any layout is drawn. + */ +public class ShortcutTrampolineActivity extends Activity { + private static final String TAG = "ShortcutTrampoline"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + try { + Intent intent = getIntent(); + if (intent == null) { + return; + } + + Uri uri = intent.getData(); + if (uri == null) { + return; + } + // Re-parse URI to prevent custom Parcelable Uri spoofing. + uri = Uri.parse(uri.toString()); + + LauncherActivityMetadata metadata = LauncherActivityMetadata.parse(this); + if (!isTrusted(uri, metadata)) { + Log.w(TAG, "Dropping untrusted shortcut URI: " + uri); + return; + } + + // Using getApplicationContext() is critical here because this Activity is going to + // finish immediately, while the TwaLauncher will do asynchronous work (connecting + // to Custom Tabs Service) and eventually launch the TWA. + Context appContext = getApplicationContext(); + TwaLauncher twaLauncher = new TwaLauncher(appContext, metadata.launchingBrowser) { + @Override + protected TrustedWebActivityIntent onPrepareIntent(TrustedWebActivityIntent intent) { + intent.getIntent().addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return super.onPrepareIntent(intent); + } + }; + + TrustedWebActivityIntentBuilder builder = new TrustedWebActivityIntentBuilder(uri); + metadata.configureIntentBuilder(builder, appContext); + + twaLauncher.launch( + builder, + new QualityEnforcer(), + null /* splashScreenStrategy */, + () -> new Handler(Looper.getMainLooper()).post(twaLauncher::destroy), + new TwaLauncher.FallbackStrategy() { + @Override + public void launch(Context context, TrustedWebActivityIntentBuilder twaBuilder, + @Nullable String providerPackage, @Nullable Runnable completionCallback) { + // Respect the metadata specified in the manifest instead of fallback. + if (metadata.launchingBrowser != null) { + Log.w(TAG, "Launching browser " + metadata.launchingBrowser + " is not available."); + if(completionCallback != null) { + completionCallback.run(); + } + return; + } + + if ("webview".equalsIgnoreCase(metadata.fallbackStrategyType)) { + Intent fallbackIntent = WebViewFallbackActivity.createLaunchIntent(context, + twaBuilder.getUri(), metadata); + fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(fallbackIntent); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "Failed to launch webview fallback: ", e); + } + } else { + // CustomTabs fallback + if (providerPackage == null) { + providerPackage = CustomTabsClient.getPackageName(context, null); + } + CustomTabsIntent customTabsIntent = twaBuilder.buildCustomTabsIntent(); + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (providerPackage != null) { + customTabsIntent.intent.setPackage(providerPackage); + } + if (ChromeOsSupport.isRunningOnArc(context.getPackageManager())) { + customTabsIntent.intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true); + } + // Verify there is an app available to handle the intent before launching + customTabsIntent.intent.setData(twaBuilder.getUri()); + if (customTabsIntent.intent.resolveActivity(context.getPackageManager()) != null) { + context.startActivity(customTabsIntent.intent); + } else { + Log.e(TAG, "No browser installed to handle Custom Tabs/Browser fallback."); + } + } + if (completionCallback != null) { + completionCallback.run(); + } + } + } + ); + + } finally { + // Must finish synchronously in onCreate() to satisfy android:noDisplay="true" + finish(); + } + } + + private static boolean isTrusted(Uri uri, LauncherActivityMetadata metadata) { + if (uri == null) { + return false; + } + if (metadata.defaultUrl != null) { + Uri defaultUri = Uri.parse(metadata.defaultUrl); + if (isSameOrigin(uri, defaultUri)) { + return true; + } + } + if (metadata.additionalTrustedOrigins != null) { + for (String originStr : metadata.additionalTrustedOrigins) { + Uri originUri = Uri.parse(originStr); + if (isSameOrigin(uri, originUri)) { + return true; + } + } + } + return false; + } + + private static boolean isSameOrigin(Uri uri1, Uri uri2) { + if (uri1 == null || uri2 == null) { + return false; + } + String scheme1 = uri1.getScheme(); + String scheme2 = uri2.getScheme(); + String host1 = uri1.getHost(); + String host2 = uri2.getHost(); + if (scheme1 == null || scheme2 == null || host1 == null || host2 == null) { + return false; + } + + int port1 = uri1.getPort(); + int port2 = uri2.getPort(); + if (port1 == -1) { + port1 = "https".equalsIgnoreCase(scheme1) ? 443 : ("http".equalsIgnoreCase(scheme1) ? 80 : -1); + } + if (port2 == -1) { + port2 = "https".equalsIgnoreCase(scheme2) ? 443 : ("http".equalsIgnoreCase(scheme2) ? 80 : -1); + } + + return scheme1.equalsIgnoreCase(scheme2) && + host1.equalsIgnoreCase(host2) && + port1 == port2; + } +} diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java index f0e27fe8..0d889756 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java @@ -158,6 +158,8 @@ public static FallbackStrategy getBlockedDialogFallbackStrategy(@Nullable String private long mStartupUptimeMillis; + private boolean mServiceBound; + public interface FallbackStrategy { void launch(Context context, TrustedWebActivityIntentBuilder twaBuilder, @@ -322,9 +324,9 @@ private void launchTwa(TrustedWebActivityIntentBuilder twaBuilder, mServiceConnection.setSessionCreationRunnables( onSessionCreatedRunnable, onSessionCreationFailedRunnable); - boolean bound = CustomTabsClient.bindCustomTabsServicePreservePriority( + mServiceBound = CustomTabsClient.bindCustomTabsServicePreservePriority( mContext, mProviderPackage, mServiceConnection); - if (!bound) { + if (!mServiceBound) { onSessionCreationFailedRunnable.run(); } } @@ -385,8 +387,9 @@ public void destroy() { if (mDestroyed) { return; } - if (mServiceConnection != null) { + if (mServiceBound && mServiceConnection != null) { mContext.unbindService(mServiceConnection); + mServiceBound = false; } mContext = null; mDestroyed = true; diff --git a/androidbrowserhelper/src/test/java/com/google/androidbrowserhelper/trusted/ShortcutTrampolineActivityTest.java b/androidbrowserhelper/src/test/java/com/google/androidbrowserhelper/trusted/ShortcutTrampolineActivityTest.java new file mode 100644 index 00000000..da71d919 --- /dev/null +++ b/androidbrowserhelper/src/test/java/com/google/androidbrowserhelper/trusted/ShortcutTrampolineActivityTest.java @@ -0,0 +1,144 @@ +// Copyright 2026 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.androidbrowserhelper.trusted; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Looper; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.internal.DoNotInstrument; +import org.robolectric.shadows.ShadowApplication; +import org.robolectric.shadows.ShadowPackageManager; + +@RunWith(RobolectricTestRunner.class) +@DoNotInstrument +@Config(sdk = {Build.VERSION_CODES.O_MR1}) +public class ShortcutTrampolineActivityTest { + private Context mContext; + private ShadowPackageManager mShadowPackageManager; + + private static final String DEFAULT_URL = "https://www.example.com/twa/home"; + + @Before + public void setUp() { + mContext = RuntimeEnvironment.application; + mShadowPackageManager = shadowOf(mContext.getPackageManager()); + + // Set up the package info with metadata on a dummy LauncherActivity + PackageInfo packageInfo = new PackageInfo(); + packageInfo.packageName = mContext.getPackageName(); + + ActivityInfo dummyLauncherActivity = new ActivityInfo(); + dummyLauncherActivity.packageName = mContext.getPackageName(); + dummyLauncherActivity.name = LauncherActivity.class.getName(); + dummyLauncherActivity.metaData = new Bundle(); + dummyLauncherActivity.metaData.putString("android.support.customtabs.trusted.DEFAULT_URL", DEFAULT_URL); + + ActivityInfo trampolineActivity = new ActivityInfo(); + trampolineActivity.packageName = mContext.getPackageName(); + trampolineActivity.name = ShortcutTrampolineActivity.class.getName(); + + // Register a fake browser that can handle HTTP/HTTPS intents + // so that resolveActivity() succeeds in the fallback strategy. + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.example.com/twa/shortcut")); + ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = new ActivityInfo(); + resolveInfo.activityInfo.packageName = "com.android.chrome"; + resolveInfo.activityInfo.name = "com.android.chrome.ChromeTabbedActivity"; + mShadowPackageManager.addResolveInfoForIntent(browserIntent, resolveInfo); + + packageInfo.activities = new ActivityInfo[]{dummyLauncherActivity, trampolineActivity}; + mShadowPackageManager.addPackage(packageInfo); + } + + @Test + public void activityFinishesSynchronously() { + Intent intent = new Intent(Intent.ACTION_VIEW) + .setData(Uri.parse("https://www.example.com/twa/shortcut")); + + ActivityController controller = + Robolectric.buildActivity(ShortcutTrampolineActivity.class, intent); + + controller.create(); + + assertTrue(controller.get().isFinishing()); + } + + @Test + public void launchesTwaForTrustedUri() { + Uri trustedUri = Uri.parse("https://www.example.com/twa/shortcut"); + Intent intent = new Intent(Intent.ACTION_VIEW).setData(trustedUri); + + ActivityController controller = + Robolectric.buildActivity(ShortcutTrampolineActivity.class, intent); + + controller.create(); + shadowOf(Looper.getMainLooper()).idle(); + + // The activity should finish immediately. + assertTrue(controller.get().isFinishing()); + + // Since we didn't set up custom tabs service, it will use the fallback strategy. + // The fallback strategy uses the application context to start the intent, which + // should be registered in the shadow application. + Intent launchedIntent = shadowOf(RuntimeEnvironment.application).getNextStartedActivity(); + assertNotNull(launchedIntent); + assertEquals(Intent.ACTION_VIEW, launchedIntent.getAction()); + assertEquals(trustedUri, launchedIntent.getData()); + + // Ensure FLAG_ACTIVITY_NEW_TASK is attached + int flags = launchedIntent.getFlags(); + assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK, flags & Intent.FLAG_ACTIVITY_NEW_TASK); + } + + @Test + public void dropsUntrustedUri() { + Uri untrustedUri = Uri.parse("https://www.evil.com/twa/shortcut"); + Intent intent = new Intent(Intent.ACTION_VIEW).setData(untrustedUri); + + ActivityController controller = + Robolectric.buildActivity(ShortcutTrampolineActivity.class, intent); + + controller.create(); + + // The activity should finish immediately. + assertTrue(controller.get().isFinishing()); + + // No activity should be launched because the URI is untrusted. + Intent launchedIntent = shadowOf(RuntimeEnvironment.application).getNextStartedActivity(); + assertNull(launchedIntent); + } +} diff --git a/demos/twa-notification-high-priority/src/main/AndroidManifest.xml b/demos/twa-notification-high-priority/src/main/AndroidManifest.xml index b98e72a8..b2c84118 100644 --- a/demos/twa-notification-high-priority/src/main/AndroidManifest.xml +++ b/demos/twa-notification-high-priority/src/main/AndroidManifest.xml @@ -68,6 +68,9 @@ + + diff --git a/demos/twa-notification-high-priority/src/main/res/values/strings.xml b/demos/twa-notification-high-priority/src/main/res/values/strings.xml index 5e38e384..dc157cab 100644 --- a/demos/twa-notification-high-priority/src/main/res/values/strings.xml +++ b/demos/twa-notification-high-priority/src/main/res/values/strings.xml @@ -21,4 +21,6 @@ }] com.google.browser.examples.twa_notification_high_priority.fileprovider + TWA Shortcut + Open TWA via Shortcut diff --git a/demos/twa-notification-high-priority/src/main/res/xml/shortcuts.xml b/demos/twa-notification-high-priority/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..354deaeb --- /dev/null +++ b/demos/twa-notification-high-priority/src/main/res/xml/shortcuts.xml @@ -0,0 +1,27 @@ + + + + + + +