Skip to content

Commit b2e1b81

Browse files
authored
Add a dialogue for when custom tab fallback fails. (#575)
This applies when there is no package available for any of the options (browser, trusted web activity, or custom tab). When the custom tab fallback itself fails because there is no provider available, there is currently an exception thrown somewhere in the Android package management code. Visually, the user sees a window open, there's some flickering from lots of retries to launch a custom tab, and then it gives up and fails. The most prominent case for this is when Android's parental controls are active and they have blocked Chrome (or whichever browser is installed on the device). To make this case clearer to the user, this introduces a dialogue telling the user that their browser is blocked and provides a link to parental controls in settings. There are admittedly some use cases where this can happen for reasons other than parental controls, but this was deemed not worth the complexity since this is a short term solution for a very niche case. * Create a constant for the parental control action. * Revert fallsBackToCustomTab_whenSessionCreationFails accidental change. * More clean up of TwaLauncher. * Remove neutral button and its associated string from TwaLauncher
1 parent 0287c45 commit b2e1b81

File tree

3 files changed

+110
-0
lines changed

3 files changed

+110
-0
lines changed

androidbrowserhelper/src/androidTest/java/com/google/androidbrowserhelper/trusted/TwaLauncherTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import static org.junit.Assert.assertNotNull;
2323
import static org.junit.Assert.assertTrue;
2424
import static org.mockito.ArgumentMatchers.any;
25+
import static org.mockito.ArgumentMatchers.anyInt;
2526
import static org.mockito.ArgumentMatchers.eq;
2627
import static org.mockito.Mockito.doAnswer;
28+
import static org.mockito.Mockito.doReturn;
2729
import static org.mockito.Mockito.mock;
2830
import static org.mockito.Mockito.never;
2931
import static org.mockito.Mockito.spy;
@@ -33,6 +35,7 @@
3335

3436
import android.content.Context;
3537
import android.content.Intent;
38+
import android.content.pm.PackageManager;
3639
import android.net.Uri;
3740

3841
import com.google.androidbrowserhelper.trusted.splashscreens.SplashScreenStrategy;
@@ -57,6 +60,7 @@
5760
import androidx.test.rule.ActivityTestRule;
5861
import androidx.test.ext.junit.runners.AndroidJUnit4;
5962

63+
import java.util.Collections;
6064
import java.util.concurrent.CountDownLatch;
6165
import java.util.concurrent.TimeUnit;
6266
import java.util.concurrent.TimeoutException;
@@ -100,6 +104,7 @@ public void setUp() {
100104
@After
101105
public void tearDown() {
102106
TwaProviderPicker.restrictToPackageForTesting(null);
107+
TwaLauncher.setDialogStrategyForTesting(null);
103108
mTwaLauncher.destroy();
104109
}
105110

@@ -278,6 +283,35 @@ public void cancelsLaunch_IfSplashScreenStrategyFinishes_AfterDestroy() throws E
278283
verify(builder, never()).build(any());
279284
}
280285

286+
@Test
287+
public void showsBrowserUnavailableDialog() throws Exception {
288+
// Disable all browsers and services in the test package.
289+
mEnableComponents.manuallyDisable(TestBrowser.class);
290+
mEnableComponents.manuallyDisable(TestCustomTabsServiceSupportsTwas.class);
291+
mEnableComponents.manuallyDisable(TestCustomTabsService.class);
292+
293+
// Mock PackageManager to return no Custom Tabs providers and no browsers.
294+
// This forces CustomTabsClient.getPackageName to return null.
295+
PackageManager pmSpy = spy(mContext.getPackageManager());
296+
doReturn(Collections.emptyList()).when(pmSpy).queryIntentServices(any(), anyInt());
297+
doReturn(Collections.emptyList()).when(pmSpy).queryIntentActivities(any(), anyInt());
298+
doReturn(null).when(pmSpy).resolveActivity(any(), anyInt());
299+
TestActivity activitySpy = spy(mActivity);
300+
doReturn(pmSpy).when(activitySpy).getPackageManager();
301+
doReturn(activitySpy).when(activitySpy).getApplicationContext();
302+
TwaLauncher.BrowserUnavailableDialogStrategy mockStrategy =
303+
mock(TwaLauncher.BrowserUnavailableDialogStrategy.class);
304+
TwaLauncher.setDialogStrategyForTesting(mockStrategy);
305+
TwaLauncher launcher = new TwaLauncher(activitySpy);
306+
307+
mActivity.runOnUiThread(() -> launcher.launch(new TrustedWebActivityIntentBuilder(URL),
308+
mCustomTabsCallback, null, null, TwaLauncher.CCT_FALLBACK_STRATEGY));
309+
310+
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
311+
verify(mockStrategy).show(any());
312+
assertNotNull(launcher);
313+
}
314+
281315
private TrustedWebActivityIntentBuilder makeBuilder() {
282316
return new TrustedWebActivityIntentBuilder(URL);
283317
}

androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414

1515
package com.google.androidbrowserhelper.trusted;
1616

17+
18+
import android.app.Activity;
19+
import android.app.AlertDialog;
20+
import android.content.ActivityNotFoundException;
1721
import android.content.ComponentName;
1822
import android.content.Context;
1923
import android.content.Intent;
24+
import android.content.pm.PackageManager;
25+
import android.content.pm.ResolveInfo;
2026
import android.net.Uri;
2127
import android.util.Log;
2228

@@ -34,8 +40,11 @@
3440
import androidx.browser.trusted.TokenStore;
3541
import androidx.browser.trusted.TrustedWebActivityIntent;
3642
import androidx.browser.trusted.TrustedWebActivityIntentBuilder;
43+
import com.google.androidbrowserhelper.R;
44+
import java.util.List;
3745

3846
import com.google.androidbrowserhelper.BuildConfig;
47+
import androidx.annotation.VisibleForTesting;
3948

4049
/**
4150
* Encapsulates the steps necessary to launch a Trusted Web Activity, such as establishing a
@@ -52,8 +61,38 @@ public class TwaLauncher {
5261
private static final String EXTRA_ANDROID_BROWSER_HELPER_VERSION =
5362
"org.chromium.chrome.browser.ANDROID_BROWSER_HELPER_VERSION";
5463

64+
65+
66+
/**
67+
* Strategy for showing a dialog when no browser is available. Interface exists just to
68+
* facilitate unit testing.
69+
*/
70+
public interface BrowserUnavailableDialogStrategy {
71+
void show(Activity activity);
72+
}
73+
74+
private static BrowserUnavailableDialogStrategy sDialogStrategy =
75+
TwaLauncher::showBrowserUnavailableDialog;
76+
77+
/** For testing: allows mocking the dialog show. */
78+
@VisibleForTesting
79+
public static void setDialogStrategyForTesting(BrowserUnavailableDialogStrategy strategy) {
80+
sDialogStrategy = strategy;
81+
}
82+
5583
public static final FallbackStrategy CCT_FALLBACK_STRATEGY =
5684
(context, twaBuilder, providerPackage, completionCallback) -> {
85+
if (providerPackage == null) {
86+
providerPackage = CustomTabsClient.getPackageName(context, null);
87+
}
88+
if (providerPackage == null) {
89+
if (context instanceof Activity) {
90+
sDialogStrategy.show((Activity) context);
91+
} else {
92+
Log.e(TAG, "Cannot show browser unavailable dialog without an Activity context.");
93+
}
94+
return;
95+
}
5796
// CustomTabsIntent will fall back to launching the Browser if there are no Custom Tabs
5897
// providers installed.
5998
CustomTabsIntent intent = twaBuilder.buildCustomTabsIntent();
@@ -338,6 +377,39 @@ public void setStartupUptimeMillis(long startupUptimeMillis) {
338377
mStartupUptimeMillis = startupUptimeMillis;
339378
}
340379

380+
/**
381+
* Shows a dialog explaining that no browser is available to open the URL.
382+
*
383+
* @param activity The {@link Activity} used to show the dialog.
384+
*/
385+
private static void showBrowserUnavailableDialog(Activity activity) {
386+
PackageManager pm = activity.getPackageManager();
387+
388+
// Query for all browsers that can handle a standard URL. This allows us to find the
389+
// user's preferred browser to provide a helpful name in the dialog.
390+
Intent queryBrowsersIntent = new Intent()
391+
.setAction(Intent.ACTION_VIEW)
392+
.addCategory(Intent.CATEGORY_BROWSABLE)
393+
.setData(Uri.fromParts("http", "", null));
394+
395+
List<ResolveInfo> allBrowsers = pm.queryIntentActivities(queryBrowsersIntent,
396+
PackageManager.MATCH_DEFAULT_ONLY | PackageManager.MATCH_UNINSTALLED_PACKAGES);
397+
398+
String browserName = activity.getString(R.string.provider_unavailable_default_browser);
399+
if (!allBrowsers.isEmpty()) {
400+
// The list is ordered from best to worst match, so we pick the first one.
401+
browserName = allBrowsers.get(0).loadLabel(pm).toString();
402+
}
403+
404+
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
405+
builder.setTitle(R.string.provider_unavailable_title)
406+
.setMessage(activity.getString(R.string.provider_unavailable_message, browserName))
407+
.setPositiveButton(R.string.provider_unavailable_button_ok, (dialog, which) -> dialog.dismiss())
408+
.setCancelable(true);
409+
builder.setOnDismissListener(dialog -> activity.finish());
410+
builder.show();
411+
}
412+
341413
private class TwaCustomTabsServiceConnection extends CustomTabsServiceConnection {
342414
private Runnable mOnSessionCreatedRunnable;
343415
private Runnable mOnSessionCreationFailedRunnable;

androidbrowserhelper/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@
44
<string name="no_provider_toast">Please install Chrome Stable 72 or later.</string>
55
<string name="manage_space_not_supported_toast">This app\'s data is stored in %1$s.</string>
66
<string name="manage_space_no_data_toast">This app holds no browsing data.</string>
7+
<string name="provider_unavailable_title">App Unavailable</string>
8+
<string name="provider_unavailable_message">This app is unavailable because %1$s is blocked</string>
9+
<string name="provider_unavailable_button_ok">OK</string>
10+
<string name="provider_unavailable_default_browser">Chrome</string>
711
</resources>

0 commit comments

Comments
 (0)