diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c0b95c7f..2932e50a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,9 +9,9 @@ jobs: steps: - uses: actions/checkout@v2 - - name: set up JDK 1.8 + - name: set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 1.8 + java-version: 17 - name: Build with Gradle run: ./gradlew build diff --git a/README.md b/README.md index b580b7db..35668924 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Android Browser Helper -![CI Status Badge](https://github.com/GoogleChrome/android-browser-helper/workflows/Android%20CI/badge.svg) +![CI Status Badge](https://github.com/GoogleChrome/android-browser-helper/actions/workflows/android.yml/badge.svg?branch=main) The Android Browser Helper library helps developers use Custom Tabs and Trusted Web Activities on top of the AndroidX browser support library. @@ -19,7 +19,7 @@ Android Browser helper is available on the Google Maven. To use it, modify your ```gradle dependencies { //... - implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.3.0' + implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.4.0' } ``` diff --git a/androidbrowserhelper/build.gradle b/androidbrowserhelper/build.gradle index ccd86b87..502e5928 100644 --- a/androidbrowserhelper/build.gradle +++ b/androidbrowserhelper/build.gradle @@ -17,17 +17,24 @@ apply plugin: 'com.android.library' apply plugin: 'maven-publish' -def VERSION = "2.4.0"; +// Before 2.6.1, the version code was unused and was kept at 1. +// This is now being used to report metrics, and should be bumped with +// every version bump. +def VERSION = "2.6.1"; +def VERSION_CODE = 1; android { - compileSdkVersion 31 + namespace "com.google.androidbrowserhelper" defaultConfig { - minSdkVersion 16 + minSdkVersion 21 + compileSdk 36 targetSdkVersion 31 - versionCode 1 + versionCode VERSION_CODE versionName VERSION + buildConfigField "int", "LIBRARY_VERSION", "${versionCode}" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -38,9 +45,13 @@ android { } } + buildFeatures { + buildConfig true + } + compileOptions { - sourceCompatibility = 1.8 - targetCompatibility = 1.8 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } testOptions { @@ -51,16 +62,21 @@ android { } dependencies { - api 'androidx.browser:browser:1.4.0' + // Force newest versions of kotlin and coroutines to avoid duplicate classes in kotlin-stdlib + implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.22")) + implementation(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3")) + + api 'androidx.browser:browser:1.9.0-alpha04' implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.annotation:annotation:1.1.0' implementation 'androidx.core:core:1.0.2' + implementation 'androidx.appcompat:appcompat:1.7.0' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:3.0.0' - testImplementation 'org.robolectric:robolectric:4.4' + testImplementation 'org.robolectric:robolectric:4.12.2' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:rules:1.2.0' diff --git a/androidbrowserhelper/src/androidTest/AndroidManifest.xml b/androidbrowserhelper/src/androidTest/AndroidManifest.xml index 2cec8e7d..c58d1574 100644 --- a/androidbrowserhelper/src/androidTest/AndroidManifest.xml +++ b/androidbrowserhelper/src/androidTest/AndroidManifest.xml @@ -14,10 +14,22 @@ limitations under the License. --> + xmlns:tools="http://schemas.android.com/tools"> + + + + android:enabled="false" android:exported="true"> @@ -43,14 +55,14 @@ + android:enabled="false" android:exported="true"> + android:enabled="false" android:exported="true"> @@ -60,7 +72,7 @@ + android:enabled="false" android:exported="true"> @@ -82,9 +94,10 @@ + android:label="Test Launcher Activity" + android:enabled="false" + android:exported="true"> diff --git a/androidbrowserhelper/src/androidTest/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategyTest.java b/androidbrowserhelper/src/androidTest/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategyTest.java index 4f857161..c927412d 100644 --- a/androidbrowserhelper/src/androidTest/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategyTest.java +++ b/androidbrowserhelper/src/androidTest/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategyTest.java @@ -100,7 +100,7 @@ public void setUp() { mActivity = mActivityTestRule.getActivity(); mSession = mConnectionRule.establishSessionBlocking(mActivity); mStrategy = new PwaWrapperSplashScreenStrategy(mActivity, R.drawable.splash, 0, - ImageView.ScaleType.FIT_XY, null, 0, FILE_PROVIDER_AUTHORITY); + ImageView.ScaleType.FIT_XY, null, 0, FILE_PROVIDER_AUTHORITY, false); } @After @@ -145,7 +145,7 @@ public void setsParametersOnTwaBuilder() throws InterruptedException { PwaWrapperSplashScreenStrategy strategy = new PwaWrapperSplashScreenStrategy(mActivity, R.drawable.splash, bgColor, scaleType, matrix, fadeOutDuration, - FILE_PROVIDER_AUTHORITY); + FILE_PROVIDER_AUTHORITY, false); strategy.onActivityEnterAnimationComplete(); initiateLaunch(strategy); 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 3536ba64..8aa9aba7 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivity.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivity.java @@ -16,9 +16,11 @@ import android.app.Activity; import android.content.Intent; +import android.content.pm.PackageManager; import android.graphics.Matrix; import android.net.Uri; import android.os.Bundle; +import android.os.SystemClock; import android.util.Log; import android.widget.ImageView; @@ -27,6 +29,7 @@ import androidx.browser.customtabs.CustomTabColorSchemeParams; import androidx.browser.customtabs.CustomTabsCallback; import androidx.browser.customtabs.CustomTabsIntent; +import androidx.browser.trusted.FileHandlingData; import androidx.browser.trusted.TrustedWebActivityDisplayMode; import androidx.browser.trusted.TrustedWebActivityIntentBuilder; import androidx.browser.trusted.TrustedWebActivityService; @@ -38,6 +41,11 @@ import org.json.JSONException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + /** * A convenience class to make using Trusted Web Activities easier. You can extend this class for * basic modifications to the behaviour. @@ -115,15 +123,16 @@ public class LauncherActivity extends Activity { @Nullable private PwaWrapperSplashScreenStrategy mSplashScreenStrategy; - private CustomTabsCallback mCustomTabsCallback = new QualityEnforcer(); - @Nullable private TwaLauncher mTwaLauncher; + private long mStartupUptimeMillis; + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + mStartupUptimeMillis = SystemClock.uptimeMillis(); sLauncherActivitiesAlive++; boolean twaAlreadyRunning = sLauncherActivitiesAlive > 1; boolean intentHasData = getIntent().getData() != null; @@ -164,7 +173,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { getSplashImageScaleType(), getSplashImageTransformationMatrix(), mMetadata.splashScreenFadeOutDurationMillis, - mMetadata.fileProviderAuthority); + mMetadata.fileProviderAuthority, + mMetadata.startChromeBeforeAnimationComplete); } if (shouldLaunchImmediately()) { @@ -202,8 +212,9 @@ protected void launchTwa() { getColorCompat(mMetadata.navigationBarDividerColorDarkId)) .build(); + Uri launchUrl = getLaunchingUrl(); TrustedWebActivityIntentBuilder twaBuilder = - new TrustedWebActivityIntentBuilder(getLaunchingUrl()) + new TrustedWebActivityIntentBuilder(launchUrl) .setToolbarColor(getColorCompat(mMetadata.statusBarColorId)) .setNavigationBarColor(getColorCompat(mMetadata.navigationBarColorId)) .setNavigationBarDividerColor( @@ -212,17 +223,25 @@ protected void launchTwa() { .setColorSchemeParams( CustomTabsIntent.COLOR_SCHEME_DARK, darkModeColorScheme) .setDisplayMode(getDisplayMode()) - .setScreenOrientation(mMetadata.screenOrientation); + .setScreenOrientation(mMetadata.screenOrientation) + .setLaunchHandlerClientMode(mMetadata.launchHandlerClientMode); + + Uri intentUrl = getIntent().getData(); + if (!launchUrl.equals(intentUrl)) { + twaBuilder.setOriginalLaunchUrl(intentUrl); + } if (mMetadata.additionalTrustedOrigins != null) { twaBuilder.setAdditionalTrustedOrigins(mMetadata.additionalTrustedOrigins); } addShareDataIfPresent(twaBuilder); + addFileDataIfPresent(twaBuilder); mTwaLauncher = createTwaLauncher(); + mTwaLauncher.setStartupUptimeMillis(mStartupUptimeMillis); mTwaLauncher.launch(twaBuilder, - mCustomTabsCallback, + getCustomTabsCallback(), mSplashScreenStrategy, () -> mBrowserWasLaunched = true, getFallbackStrategy()); @@ -244,8 +263,12 @@ protected void launchTwa() { mTwaLauncher.getProviderPackage()); } + protected CustomTabsCallback getCustomTabsCallback() { + return new QualityEnforcer(); + } + protected TwaLauncher createTwaLauncher() { - return new TwaLauncher(this); + return new TwaLauncher(this, getTaskId()); } private boolean splashScreenNeeded() { @@ -276,6 +299,31 @@ private void addShareDataIfPresent(TrustedWebActivityIntentBuilder twaBuilder) { } } + private void addFileDataIfPresent(TrustedWebActivityIntentBuilder twaBuilder) { + List uris; + + if (getIntent().hasExtra(TrustedWebActivityIntentBuilder.EXTRA_FILE_HANDLING_DATA)) { + Bundle bundle = getIntent().getBundleExtra(TrustedWebActivityIntentBuilder.EXTRA_FILE_HANDLING_DATA); + if (bundle == null) return; + uris = FileHandlingData.fromBundle(bundle).uris; + } else { + uris = Arrays.asList(getIntent().getData()); + } + + for (Uri uri : uris) { + if (uri == null || !"content".equals(uri.getScheme())) return; + + int granted = checkCallingOrSelfUriPermission(uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (granted != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Failed to open a file - no read / write permissions: " + uri); + return; + } + } + + twaBuilder.setFileHandlingData(new FileHandlingData(uris)); + } + /** * Override to set a custom scale type for the image displayed on a splash screen. * See {@link ImageView.ScaleType}. @@ -328,6 +376,23 @@ protected void onSaveInstanceState(Bundle outState) { outState.putBoolean(BROWSER_WAS_LAUNCHED_KEY, mBrowserWasLaunched); } + /** + * Override this to enable Protocol Handler support. + * Keys of this map are data schemes, e.g. "bitcoin", "irc", "xmpp", "web+coffee", and values + * are templates that will be used to construct the full URL. The template must contain a "%s" + * token and be an absolute location with http/https scheme and the same origin as the TWA. + * + * An example valid entry in the map would be: + * ["web+coffee"] -> ["https://coffee.com/?type=%s"] + * This would result in a link "web+coffee://latte" being converted to + * "https://coffee.com/?type=web%2Bcoffee%3A%2F%2Flatte". + * + * {@see https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/protocol_handlers} + */ + protected Map getProtocolHandlers() { + return Collections.emptyMap(); + } + @Override public void onEnterAnimationComplete() { super.onEnterAnimationComplete(); @@ -345,18 +410,42 @@ public void onEnterAnimationComplete() { * Override this for special handling (such as ignoring or sanitising data from the Intent). */ protected Uri getLaunchingUrl() { - Uri uri = getIntent().getData(); - if (uri != null) { - Log.d(TAG, "Using URL from Intent (" + uri + ")."); - return uri; - } - - if (mMetadata.defaultUrl != null) { - Log.d(TAG, "Using URL from Manifest (" + mMetadata.defaultUrl + ")."); - return Uri.parse(mMetadata.defaultUrl); + Uri defaultUrl = Uri.parse(mMetadata.defaultUrl); + + Uri intentUrl = getIntent().getData(); + + if (intentUrl != null) { + Map protocolHandlers = getProtocolHandlers(); + String scheme = intentUrl.getScheme(); + + if ("https".equals(scheme)) { + Log.d(TAG, "Using url from Intent: " + intentUrl); + return intentUrl; + } + + if ("content".equals(scheme)) { + // The application was launched by opening a file - return the URL configured for + // this file type in the manifest + if (mMetadata.fileHandlingActionUrl == null) { + return defaultUrl; + } + return Uri.parse(mMetadata.fileHandlingActionUrl); + } + + Uri format = protocolHandlers.get(scheme); + if (format != null) { + String target = Uri.encode(intentUrl.toString()); + Uri targetUrl = Uri.parse(String.format(format.toString(), target)); + Log.d(TAG, "Using protocol handler url: " + targetUrl); + return targetUrl; + } + + Log.w(TAG, "Scheme " + scheme + " was registered in the manifest but not in " + + "getProtocolHandlers()! Ignoring it and falling back to the default url."); } - return Uri.parse("https://www.example.com/"); + Log.d(TAG, "Using url from Manifest: " + defaultUrl); + return defaultUrl; } /** 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 04f9ad1e..50a84d05 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/LauncherActivityMetadata.java @@ -14,19 +14,23 @@ package com.google.androidbrowserhelper.trusted; +import android.app.Activity; import android.content.ComponentName; import android.content.Context; +import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.res.Resources; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.browser.trusted.LaunchHandlerClientMode; import androidx.browser.trusted.ScreenOrientation; import androidx.browser.trusted.TrustedWebActivityDisplayMode; import java.util.Arrays; import java.util.List; +import java.util.Map; import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; @@ -131,7 +135,7 @@ public class LauncherActivityMetadata { /** * The display mode to use when launching the Trusted Web Activity. Possible values are - * "default", "immersive" and "sticky-immersive". + * "default", "immersive", "sticky-immersive", "minimal-ui", and "browser". */ private static final String METADATA_DISPLAY_MODE = "android.support.customtabs.trusted.DISPLAY_MODE"; @@ -145,6 +149,30 @@ public class LauncherActivityMetadata { private static final String METADATA_SCREEN_ORIENTATION = "android.support.customtabs.trusted.SCREEN_ORIENTATION"; + /** + * Url to launch in a Trusted Web Activity when handling a file + */ + private static final String METADATA_FILE_HANDLING_ACTION_URL = + "android.support.customtabs.trusted.FILE_HANDLING_ACTION_URL"; + + /** + * Client mode of Launch Handler API. Describes how TWA will be launched. For example opening + * a new tasks or taking an action to an existing one. + */ + private static final String LAUNCH_HANDLER_CLIENT_MODE_METADATA_NAME + = "android.support.customtabs.trusted.LAUNCH_HANDLER_CLIENT_MODE"; + private static final Map LAUNCH_HANDLER_CLIENT_MODE_MAP = + Map.of( + "navigate-existing", LaunchHandlerClientMode.NAVIGATE_EXISTING, + "focus-existing", LaunchHandlerClientMode.FOCUS_EXISTING, + "navigate-new", LaunchHandlerClientMode.NAVIGATE_NEW, + "auto", LaunchHandlerClientMode.AUTO); + /** + * Whether to start Chrome before the enter animation is complete. Default is false. + */ + private static final String METADATA_START_CHROME_BEFORE_ANIMATION_COMPLETE = + "android.support.customtabs.trusted.START_CHROME_BEFORE_ANIMATION_COMPLETE"; + private final static int DEFAULT_COLOR_ID = android.R.color.white; private final static int DEFAULT_DIVIDER_COLOR_ID = android.R.color.transparent; @@ -164,6 +192,9 @@ public class LauncherActivityMetadata { public final TrustedWebActivityDisplayMode displayMode; @ScreenOrientation.LockType public final int screenOrientation; @Nullable public final String shareTarget; + @Nullable public final String fileHandlingActionUrl; + @LaunchHandlerClientMode.ClientMode public final int launchHandlerClientMode; + public final boolean startChromeBeforeAnimationComplete; private LauncherActivityMetadata(@NonNull Bundle metaData, @NonNull Resources resources) { defaultUrl = metaData.getString(METADATA_DEFAULT_URL); @@ -195,6 +226,11 @@ private LauncherActivityMetadata(@NonNull Bundle metaData, @NonNull Resources re screenOrientation = getOrientation(metaData.getString(METADATA_SCREEN_ORIENTATION)); int shareTargetId = metaData.getInt(METADATA_SHARE_TARGET, 0); shareTarget = shareTargetId == 0 ? null : resources.getString(shareTargetId); + fileHandlingActionUrl = metaData.getString(METADATA_FILE_HANDLING_ACTION_URL); + launchHandlerClientMode = getLaunchHandlerClientMode( + metaData.getString(LAUNCH_HANDLER_CLIENT_MODE_METADATA_NAME)); + startChromeBeforeAnimationComplete = + metaData.getBoolean(METADATA_START_CHROME_BEFORE_ANIMATION_COMPLETE, false); } private @ScreenOrientation.LockType int getOrientation(String orientation) { @@ -234,23 +270,54 @@ private static TrustedWebActivityDisplayMode getDisplayMode(@NonNull Bundle meta return new TrustedWebActivityDisplayMode.ImmersiveMode( true, LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT); } + if ("minimal-ui".equals(displayMode)) { + return new TrustedWebActivityDisplayMode.MinimalUiMode(); + } + if ("browser".equals(displayMode)) { + return new TrustedWebActivityDisplayMode.BrowserMode(); + } return new TrustedWebActivityDisplayMode.DefaultMode(); } + /** + * Returns a Launch Handler client mode in androidx format. In case it's absent or wrong in the + * metadata LaunchHandlerClientMode.AUTO is returned. + */ + private @LaunchHandlerClientMode.ClientMode int getLaunchHandlerClientMode( + String clientModeName) { + Integer clientMode = LAUNCH_HANDLER_CLIENT_MODE_MAP.get(clientModeName); + return clientMode != null ? clientMode : LaunchHandlerClientMode.AUTO; + } + /** * Creates LauncherActivityMetadata instance based on metadata of the passed Activity. */ public static LauncherActivityMetadata parse(Context context) { Resources resources = context.getResources(); - Bundle metaData = null; + Bundle metaData = new Bundle(); try { - metaData = context.getPackageManager().getActivityInfo( - new ComponentName(context, context.getClass()), - PackageManager.GET_META_DATA).metaData; + Bundle launchedComponentMetaData = context.getPackageManager().getActivityInfo( + new ComponentName(context, context.getClass()), + PackageManager.GET_META_DATA).metaData; + if (launchedComponentMetaData != null) { + metaData.putAll(launchedComponentMetaData); + } + + if (context instanceof Activity) { + Activity activity = (Activity) context; + ActivityInfo activityInfo = activity.getPackageManager().getActivityInfo( + activity.getComponentName(), + PackageManager.GET_META_DATA); + if (activityInfo.targetActivity != null && activityInfo.metaData != null) { + // The app was launched through the activity alias - + // get all the metadata from the alias too + metaData.putAll(activityInfo.metaData); + } + } } catch (PackageManager.NameNotFoundException e) { // Will only happen if the package provided (the one we are running in) is not // installed - so should never happen. } - return new LauncherActivityMetadata(metaData == null ? new Bundle() : metaData, resources); + return new LauncherActivityMetadata(metaData, resources); } } 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 95342faf..e5e97ab3 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/TwaLauncher.java @@ -34,10 +34,12 @@ import androidx.browser.trusted.TokenStore; import androidx.browser.trusted.TrustedWebActivityIntent; import androidx.browser.trusted.TrustedWebActivityIntentBuilder; -import androidx.core.content.ContextCompat; -import com.google.androidbrowserhelper.trusted.ChromeOsSupport; -import com.google.androidbrowserhelper.trusted.splashscreens.SplashScreenStrategy; +import com.google.androidbrowserhelper.BuildConfig; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; /** * Encapsulates the steps necessary to launch a Trusted Web Activity, such as establishing a @@ -46,7 +48,13 @@ public class TwaLauncher { private static final String TAG = "TwaLauncher"; - private static final int DEFAULT_SESSION_ID = 96375; + private static final Map mTaskIdToSessionId = new HashMap<>(); + + private static final String EXTRA_STARTUP_UPTIME_MILLIS = + "org.chromium.chrome.browser.customtabs.trusted.STARTUP_UPTIME_MILLIS"; + + private static final String EXTRA_ANDROID_BROWSER_HELPER_VERSION = + "org.chromium.chrome.browser.ANDROID_BROWSER_HELPER_VERSION"; public static final FallbackStrategy CCT_FALLBACK_STRATEGY = (context, twaBuilder, providerPackage, completionCallback) -> { @@ -96,6 +104,8 @@ public class TwaLauncher { private boolean mDestroyed; + private long mStartupUptimeMillis; + public interface FallbackStrategy { void launch(Context context, TrustedWebActivityIntentBuilder twaBuilder, @@ -111,12 +121,20 @@ public TwaLauncher(Context context) { this(context, null); } + /** + * Same as above, but also allows to specify a task id to distinguish several sessions running + * for the same TWA app. + */ + public TwaLauncher(Context context, @Nullable Integer taskId) { + this(context, null, taskId); + } + /** * Same as above, but also allows to specify a browser to launch. If specified, it is assumed to * support TWAs. */ - public TwaLauncher(Context context, @Nullable String providerPackage) { - this(context, providerPackage, DEFAULT_SESSION_ID, + public TwaLauncher(Context context, @Nullable String providerPackage, @Nullable Integer taskId) { + this(context, providerPackage, taskId, new SharedPreferencesTokenStore(context)); } @@ -124,10 +142,10 @@ public TwaLauncher(Context context, @Nullable String providerPackage) { * Same as above, but also accepts a session id. This allows to launch multiple TWAs in the same * task. */ - public TwaLauncher(Context context, @Nullable String providerPackage, int sessionId, + public TwaLauncher(Context context, @Nullable String providerPackage, @Nullable Integer taskId, TokenStore tokenStore) { mContext = context; - mSessionId = sessionId; + mSessionId = makeSessionId(taskId); mTokenStore = tokenStore; if (providerPackage == null) { TwaProviderPicker.Action action = @@ -140,6 +158,19 @@ public TwaLauncher(Context context, @Nullable String providerPackage, int sessio } } + private static Integer makeSessionId(@Nullable Integer taskId) { + if(taskId == null) return Integer.MAX_VALUE; + + Integer sessionId = mTaskIdToSessionId.get(taskId); + if(sessionId == null) { + Random random = new Random(); + sessionId = random.nextInt(Integer.MAX_VALUE); + mTaskIdToSessionId.put(taskId, sessionId); + } + + return sessionId; + } + /** * Opens the specified url in a TWA. * When TWA is already running in the current task, the url will be opened in existing TWA, @@ -269,6 +300,11 @@ private void launchWhenSplashScreenReady(TrustedWebActivityIntentBuilder builder } Log.d(TAG, "Launching Trusted Web Activity."); TrustedWebActivityIntent intent = builder.build(mSession); + if (mStartupUptimeMillis != 0) { + intent.getIntent().putExtra(EXTRA_STARTUP_UPTIME_MILLIS, mStartupUptimeMillis); + } + intent.getIntent().putExtra( + EXTRA_ANDROID_BROWSER_HELPER_VERSION, BuildConfig.LIBRARY_VERSION); FocusActivity.addToIntent(intent.getIntent(), mContext); intent.launchTrustedWebActivity(mContext); @@ -299,6 +335,15 @@ public String getProviderPackage() { return mProviderPackage; } + /** + * Sets the timestamp (in SystemClock.uptimeMillis()) when the TWA launcher + * activity was created. This timestamp is used to report the full startup + * duration to the browser. + */ + public void setStartupUptimeMillis(long startupUptimeMillis) { + mStartupUptimeMillis = startupUptimeMillis; + } + private class TwaCustomTabsServiceConnection extends CustomTabsServiceConnection { private Runnable mOnSessionCreatedRunnable; private Runnable mOnSessionCreationFailedRunnable; diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/WebViewFallbackActivity.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/WebViewFallbackActivity.java index 34fdbb6b..a89c678e 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/WebViewFallbackActivity.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/WebViewFallbackActivity.java @@ -29,6 +29,7 @@ import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; +import android.view.WindowManager; import android.webkit.RenderProcessGoneDetail; import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; @@ -296,10 +297,10 @@ public void onShowCustomView(View paramView, CustomViewCallback paramCustomViewC // exiting the fullscreen. this.originalOrientation = getRequestedOrientation(); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); getWindow().addContentView(this.fullScreenView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER)); - getWindow().addFlags(View.SYSTEM_UI_FLAG_FULLSCREEN); } @Override @@ -308,9 +309,10 @@ public void onHideCustomView() { if (fullScreenView == null) { return; } + + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); ((ViewGroup) fullScreenView.getParent()).removeView(fullScreenView); this.fullScreenView = null; - getWindow().clearFlags(View.SYSTEM_UI_FLAG_FULLSCREEN); setRequestedOrientation(this.originalOrientation); } }; @@ -322,5 +324,8 @@ private static void setupWebSettings(WebSettings webSettings) { webSettings.setJavaScriptEnabled(true); webSettings.setDomStorageEnabled(true); webSettings.setDatabaseEnabled(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + webSettings.setMediaPlaybackRequiresUserGesture(false); + } } } diff --git a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategy.java b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategy.java index 4918a6e9..e9b3f9be 100644 --- a/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategy.java +++ b/androidbrowserhelper/src/main/java/com/google/androidbrowserhelper/trusted/splashscreens/PwaWrapperSplashScreenStrategy.java @@ -19,6 +19,7 @@ import android.app.Activity; import android.graphics.Bitmap; import android.graphics.Matrix; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; @@ -86,6 +87,8 @@ public class PwaWrapperSplashScreenStrategy implements SplashScreenStrategy { @Nullable private Runnable mOnEnterAnimationCompleteRunnable; + private boolean mStartChromeBeforeAnimationComplete; + /** * @param activity {@link Activity} on top of which a TWA is going to be launched. * @param drawableId Resource id of the Drawable of an image (e.g. logo) displayed in the @@ -104,7 +107,8 @@ public PwaWrapperSplashScreenStrategy( ImageView.ScaleType scaleType, @Nullable Matrix transformationMatrix, int fadeOutDurationMillis, - String fileProviderAuthority) { + String fileProviderAuthority, + boolean startChromeBeforeAnimationComplete) { mDrawableId = drawableId; mBackgroundColor = backgroundColor; mScaleType = scaleType; @@ -112,6 +116,7 @@ public PwaWrapperSplashScreenStrategy( mActivity = activity; mFileProviderAuthority = fileProviderAuthority; mFadeOutDurationMillis = fadeOutDurationMillis; + mStartChromeBeforeAnimationComplete = startChromeBeforeAnimationComplete; } @Override @@ -192,11 +197,11 @@ public void configureTwaBuilder(TrustedWebActivityIntentBuilder builder, mProviderPackage); mSplashImageTransferTask.execute( - success -> onSplashImageTransferred(builder, success, onReadyCallback)); + success -> onSplashImageTransferred(builder, success, onReadyCallback, session)); } private void onSplashImageTransferred(TrustedWebActivityIntentBuilder builder, boolean success, - Runnable onReadyCallback) { + Runnable onReadyCallback, CustomTabsSession session) { if (!success) { Log.w(TAG, "Failed to transfer splash image."); onReadyCallback.run(); @@ -204,17 +209,26 @@ private void onSplashImageTransferred(TrustedWebActivityIntentBuilder builder, b } builder.setSplashScreenParams(makeSplashScreenParamsBundle()); - runWhenEnterAnimationComplete(() -> { - onReadyCallback.run(); - mActivity.overridePendingTransition(0, 0); // Avoid window animations during transition. - }); + Runnable taskToRun = () -> { + onReadyCallback.run(); + mActivity.overridePendingTransition(0, 0); // Avoid window animations during transition. + }; + + if (mStartChromeBeforeAnimationComplete) { + taskToRun.run(); + } else { + runWhenEnterAnimationComplete(taskToRun, session, builder.getUri()); + } } - private void runWhenEnterAnimationComplete(Runnable runnable) { + private void runWhenEnterAnimationComplete(Runnable runnable, CustomTabsSession session, + Uri uri) { if (mEnterAnimationComplete) { runnable.run(); } else { mOnEnterAnimationCompleteRunnable = runnable; + boolean preloadResult = session.mayLaunchUrl(uri, null, null); + Log.i(TAG, "Enter animation not complete, try preload url. Result: " + preloadResult); } } diff --git a/build.gradle b/build.gradle index c5faeab2..16a020c9 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.1' + classpath 'com.android.tools.build:gradle:8.9.1' } } @@ -30,6 +30,14 @@ allprojects { repositories { google() jcenter() + + // Repository for DexMaker + maven { + url = "https://linkedin.jfrog.io/artifactory/open-source/" + content { + includeGroup 'com.linkedin.dexmaker' + } + } } } diff --git a/demos/custom-tabs-auth-tab/.gitignore b/demos/custom-tabs-auth-tab/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/demos/custom-tabs-auth-tab/.gitignore @@ -0,0 +1 @@ +/build diff --git a/demos/custom-tabs-auth-tab/build.gradle b/demos/custom-tabs-auth-tab/build.gradle new file mode 100644 index 00000000..2005de77 --- /dev/null +++ b/demos/custom-tabs-auth-tab/build.gradle @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * 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 + * + * https://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. + */ + +apply plugin: 'com.android.application' + +android { + namespace 'com.google.androidbrowserhelper.demos.customtabsauthtab' + defaultConfig { + applicationId "com.google.androidbrowserhelper.demos.customtabsauthtab" + minSdkVersion 26 + compileSdk 36 + targetSdkVersion 35 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + implementation project(path: ':androidbrowserhelper') + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.activity:activity:1.9.3' + implementation 'androidx.browser:browser:1.9.0-alpha03' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.annotation:annotation:1.9.1' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' +} diff --git a/demos/custom-tabs-auth-tab/src/main/AndroidManifest.xml b/demos/custom-tabs-auth-tab/src/main/AndroidManifest.xml new file mode 100644 index 00000000..828ba710 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/AuthManager.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/AuthManager.java new file mode 100644 index 00000000..2290a1fd --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/AuthManager.java @@ -0,0 +1,104 @@ +package com.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.browser.auth.AuthTabIntent; +import androidx.annotation.OptIn; + +import java.io.IOException; +import java.util.UUID; + +/** + * This class helps managing an authentication flow. It was created with the goal of demonstrating + * how to use Custom Tabs Auth Tab to handle auth and is not meant as a complete implementation + * of the OAuth protocol. We recommend checking out https://github.com/openid/AppAuth-Android for + * a comprehensive implementation of the OAuth protocol. + */ + +public class AuthManager { + private static final String TAG = "OAuthManager"; + + private final String mClientId; + private final String mClientSecret; + private final String mAuthorizationEndpoint; + private final String mRedirectScheme; + + public interface OAuthCallback { + void auth(String accessToken, String scope, String tokenType); + } + + public AuthManager(String clientId, String clientSecret, String authorizationEndpoint, + String redirectScheme) { + mClientId = clientId; + mClientSecret = clientSecret; + mAuthorizationEndpoint = authorizationEndpoint; + mRedirectScheme = redirectScheme; + } + + public void authorize(Context context, ActivityResultLauncher launcher, String scope) { + // Generate a random state. + String state = UUID.randomUUID().toString(); + + // Save the state so we can verify later. + SharedPreferences preferences = + context.getSharedPreferences("OAUTH_STORAGE", Context.MODE_PRIVATE); + preferences.edit() + .putString("OAUTH_STATE", state) + .apply(); + + // Create an authorization URI to the OAuth Endpoint. + Uri uri = Uri.parse(mAuthorizationEndpoint) + .buildUpon() + .appendQueryParameter("response_type", "code") + .appendQueryParameter("client_id", mClientId) + .appendQueryParameter("scope", scope) + .appendQueryParameter("state", state) + .build(); + + // Open the Authorization URI in a Chrome Custom Auth Tab. + AuthTabIntent authTabIntent = new AuthTabIntent.Builder().build(); + authTabIntent.launch(launcher, uri, mRedirectScheme); + } + + public void continueAuthFlow(@NonNull Context context, Uri uri, @NonNull OAuthCallback callback) { + String code = uri.getQueryParameter("code"); + SharedPreferences preferences = + context.getSharedPreferences("OAUTH_STORAGE", Context.MODE_PRIVATE); + String state = preferences.getString("OAUTH_STATE", ""); + Uri tokenUri = Uri.parse("https://github.com/login/oauth/access_token") + .buildUpon() + .appendQueryParameter("client_id", mClientId) + .appendQueryParameter("client_secret", mClientSecret) + .appendQueryParameter("code", code) + .appendQueryParameter("state", state) + .build(); + + // Run the network request off the UI thread. + new Thread(() -> { + try { + String response = Utils.fetch(tokenUri); + // The response is a query-string. We concatenate with a valid domain to be + // able to easily parse and extract values. + Uri responseUri = Uri.parse("http://example.com?" + response); + String accessToken = responseUri.getQueryParameter("access_token"); + String tokenType = responseUri.getQueryParameter("token_type"); + String scope = responseUri.getQueryParameter("scope"); + + // Invoke the callback in the main thread. + new Handler(Looper.getMainLooper()).post( + () -> callback.auth(accessToken, scope, tokenType)); + + } catch (IOException e) { + Log.e(TAG, "Error requesting access token: " + e.getMessage()); + } + }).start(); + } +} diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/GithubApi.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/GithubApi.java new file mode 100644 index 00000000..1d62cdca --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/GithubApi.java @@ -0,0 +1,43 @@ +package com.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +public class GithubApi { + private static final String TAG = "GithubAPI"; + private static final String API_ENDPOINT = "https://api.github.com/user"; + private static final String AUTH_HEADER_KEY = "Authorization"; + + public interface UserCallback { + void onUserData(String username); + } + + public static void requestGithubUsername(String token, UserCallback callback) { + new Thread(() -> { + try { + Uri uri = Uri.parse(API_ENDPOINT); + Map headers = + Collections.singletonMap(AUTH_HEADER_KEY, "token " + token); + String response = Utils.fetch(uri, headers); + JSONObject user = new JSONObject(response); + String username = user.getString("name"); + + // Invoke the callback in the main thread. + new Handler(Looper.getMainLooper()).post(() -> { + callback.onUserData(username); + }); + } catch (IOException | JSONException ex) { + Log.e(TAG, "Error fetching GitHub user: " + ex.getMessage()); + } + }).start(); + } +} diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/MainActivity.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/MainActivity.java new file mode 100644 index 00000000..190bc79c --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/MainActivity.java @@ -0,0 +1,98 @@ +/* + * Copyright 2024 Google LLC + * + * 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 + * + * https://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.demos.customtabsauthtab; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.appcompat.app.AppCompatActivity; +import androidx.browser.auth.AuthTabIntent; + +public class MainActivity extends AppCompatActivity { + private static final String TAG = "MainActivity"; + + private static final String AUTHORIZATION_ENDPOINT = "https://github.com/login/oauth/authorize"; + private static final String CLIENT_ID = ""; + private static final String CLIENT_SECRET = ""; + private static final String REDIRECT_SCHEME = "auth"; + + private static final AuthManager O_AUTH_MANAGER = + new AuthManager(CLIENT_ID, CLIENT_SECRET, AUTHORIZATION_ENDPOINT, REDIRECT_SCHEME); + + private final ActivityResultLauncher mLauncher = + AuthTabIntent.registerActivityResultLauncher(this, this::handleAuthResult); + + private Button mLoginButton; + private TextView mUserText; + private ProgressBar mProgressBar; + private boolean mLoggedIn; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mLoginButton = findViewById(R.id.login_button); + mUserText = findViewById(R.id.user_text); + mProgressBar = findViewById(R.id.progress_bar); + + Intent intent = getIntent(); + if (intent != null) { + Uri data = intent.getData(); + if (data != null && data.getHost() != null + && data.getHost().startsWith("callback")) { + mProgressBar.setVisibility(View.VISIBLE); + mLoginButton.setEnabled(false); + completeAuth(data); + } + } + } + + public void login(View v) { + if (mLoggedIn) { + mLoginButton.setText(R.string.login); + mUserText.setText(R.string.logged_out); + mLoggedIn = false; + } else { + O_AUTH_MANAGER.authorize(this, mLauncher, "user"); + } + } + + private void handleAuthResult(AuthTabIntent.AuthResult result) { + if (result.resultCode == AuthTabIntent.RESULT_OK) { + completeAuth(result.resultUri); + } + } + + private void completeAuth(Uri uri) { + O_AUTH_MANAGER.continueAuthFlow(this, uri, (accessToken, scope, tokenType) -> { + GithubApi.requestGithubUsername(accessToken, (username -> { + mLoginButton.setText(R.string.logout); + mLoginButton.setEnabled(true); + mProgressBar.setVisibility(View.INVISIBLE); + mUserText.setText(getString(R.string.logged_in, username)); + mLoggedIn = true; + })); + }); + } +} \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/Utils.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/Utils.java new file mode 100644 index 00000000..ebd29121 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/Utils.java @@ -0,0 +1,49 @@ +package com.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collections; +import java.util.Map; + +public class Utils { + public static String fetch(@NonNull Uri uri) throws IOException { + return fetch(uri, Collections.emptyMap()); + } + + public static String fetch(@NonNull Uri uri, Map headers) throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL(uri.toString()); + connection = (HttpURLConnection)url.openConnection(); + for(Map.Entry entry: headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + connection.setDoOutput(true); + return inputStreamToString(connection.getInputStream()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @NonNull + public static String inputStreamToString(@NonNull InputStream inputStream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + StringBuilder builder = new StringBuilder(); + while ((line = reader.readLine()) != null) { + builder.append(line).append('\n'); + } + return builder.toString(); + } + } +} \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_background.xml b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..9e983cb3 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_foreground.xml b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..9be1fda8 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/res/layout/activity_main.xml b/demos/custom-tabs-auth-tab/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..ad1f1ec3 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/res/layout/activity_main.xml @@ -0,0 +1,42 @@ + + + + +