blob: f7380a96a3791c29689c9a34e4438c15fb817636 [file] [log] [blame]
/*
* Copyright 2018 The Android Open Source Project
*
* 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 androidx.browser.trusted;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.StrictMode;
import android.util.Log;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
/**
* A TrustedWebActivityServiceConnectionPool will be used by a Trusted Web Activity provider and
* takes care of connecting to and communicating with {@link TrustedWebActivityService}s.
* <p>
* Trusted Web Activity client apps are registered with {@link #registerClient}, associating a
* package with an origin. There may be multiple packages associated with a single origin.
* Note, the origins are essentially keys to a map of origin to package name - while they
* semantically are web origins, they aren't used that way.
* <p>
* To interact with a {@link TrustedWebActivityService}, call {@link #connect}.
*/
public final class TrustedWebActivityServiceConnectionPool {
private static final String TAG = "TWAConnectionPool";
private static final String PREFS_FILE = "TrustedWebActivityVerifiedPackages";
@SuppressWarnings("WeakerAccess") /* synthetic access */
final Context mContext;
/** Map from ServiceWorker scope to Connection. */
@SuppressWarnings("WeakerAccess") /* synthetic access */
Map<Uri, ConnectionHolder> mConnections = new HashMap<>();
private static AtomicReference<SharedPreferences> sSharedPreferences = new AtomicReference<>();
/**
* Gets the verified packages for the given origin. This is safe to be called on any thread,
* however it may hit disk the first time it is called.
*
* @param context A Context to be used for accessing SharedPreferences.
* @param origin The origin that was previously used with {@link #registerClient}.
* @return A set of package names. This set is safe to be modified.
*/
public static @NonNull Set<String> getVerifiedPackages(@NonNull Context context,
@NonNull String origin) {
// Loading preferences is on the critical path for this class - we need to synchronously
// inform the client whether or not an notification can be handled by a TWA.
// I considered loading the preferences into a cache on a background thread when this class
// was created, but ultimately if that load hadn't completed by the time {@link #connect} or
// {@link #registerClient} were called, we'd still need to block for it to complete.
// Therefore we attempt to asynchronously load the preferences in the constructor, but if
// they aren't loaded by the time they are needed, we disable StrictMode and read them on
// the main thread.
StrictMode.ThreadPolicy policy = StrictMode.allowThreadDiskReads();
try {
ensurePreferencesOpened(context);
return new HashSet<>(
sSharedPreferences.get().getStringSet(origin, Collections.<String>emptySet()));
} finally {
StrictMode.setThreadPolicy(policy);
}
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
static void ensurePreferencesOpened(@NonNull Context context) {
if (sSharedPreferences.get() == null) {
sSharedPreferences.compareAndSet(null,
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE));
}
}
private TrustedWebActivityServiceConnectionPool(@NonNull Context context) {
mContext = context.getApplicationContext();
// Asynchronously try to load (and therefore cache) the preferences.
AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() {
@Override
public void run() {
ensurePreferencesOpened(context);
}
});
}
/**
* Creates a TrustedWebActivityServiceConnectionPool.
* @param context A Context used for accessing SharedPreferences.
*/
@NonNull
public static TrustedWebActivityServiceConnectionPool create(@NonNull Context context) {
return new TrustedWebActivityServiceConnectionPool(context);
}
/**
* Connects to the appropriate {@link TrustedWebActivityService} or uses an existing connection
* if available and runs code once connected.
* <p>
* To find a Service to connect to, this method attempts to resolve an
* {@link Intent#ACTION_VIEW} Intent with the {@code scope} as data. The first of the resolved
* packages that is registered (through {@link #registerClient}) to {@code origin} will be
* chosen. Finally, an Intent with the action
* {@link TrustedWebActivityService#ACTION_TRUSTED_WEB_ACTIVITY_SERVICE} will be used to find
* the Service.
* <p>
* This method should be called on the UI thread.
*
* @param scope The scope used in an Intent to find packages that may have a
* {@link TrustedWebActivityService}.
* @param origin An origin that the {@link TrustedWebActivityService} package must be registered
* to.
* @param executor The {@link Executor} to connect to the Service on if a new connection is
* required.
* @return A {@link ListenableFuture} for the resulting
* {@link TrustedWebActivityServiceConnection}. This may be set to an
* {@link IllegalArgumentException} if no service exists for the scope (you can check
* for this beforehand by calling {@link #serviceExistsForScope(Uri, String)}. It may
* be set to a {@link SecurityException} if the Service does not accept connections from
* this app. It may be set to an {@link IllegalStateException} if connecting to the
* Service fails.
*/
@MainThread
@NonNull
public ListenableFuture<TrustedWebActivityServiceConnection> connect(
@NonNull final Uri scope, @NonNull String origin, @NonNull Executor executor) {
// If we have an existing connection, use it.
ConnectionHolder connection = mConnections.get(scope);
if (connection != null) {
return connection.getServiceWrapper();
}
// Check that this is a notification we want to handle.
final Intent bindServiceIntent = createServiceIntent(mContext, scope, origin, true);
if (bindServiceIntent == null) {
return FutureUtils.immediateFailedFuture(
new IllegalArgumentException("No service exists for scope"));
}
ConnectionHolder newConnection = new ConnectionHolder(() -> mConnections.remove(scope));
mConnections.put(scope, newConnection);
// Create a new connection.
new BindToServiceAsyncTask(mContext, bindServiceIntent, newConnection)
.executeOnExecutor(executor);
return newConnection.getServiceWrapper();
}
static class BindToServiceAsyncTask extends AsyncTask<Void, Void, Exception> {
private final Context mAppContext;
private final Intent mIntent;
private final ConnectionHolder mConnection;
BindToServiceAsyncTask(Context context, Intent intent, ConnectionHolder connection) {
mAppContext = context.getApplicationContext();
mIntent = intent;
mConnection = connection;
}
@Nullable
@Override
protected Exception doInBackground(Void... voids) {
try {
// We can pass newConnection to bindService here on a background thread because
// bindService assures us it will use newConnection on the UI thread.
if (mAppContext.bindService(mIntent, mConnection, Context.BIND_AUTO_CREATE)) {
return null;
}
mAppContext.unbindService(mConnection);
// TODO: Find a better exception to use here.
return new IllegalStateException("Could not bind to the service");
} catch (SecurityException e) {
Log.w(TAG, "SecurityException while binding.", e);
return e;
}
}
@Override
protected void onPostExecute(Exception bindingException) {
if (bindingException != null) mConnection.cancel(bindingException);
}
}
/**
* Checks if a TrustedWebActivityService exists to handle requests for the given scope and
* origin. The value will be the same as that returned from {@link #connect} so calling that
* and checking the return may be more convenient.
*
* This method should be called on the UI thread.
*
* @param scope The scope used in an Intent to find packages that may have a
* {@link TrustedWebActivityService}.
* @param origin An origin that the {@link TrustedWebActivityService} package must be registered
* to.
* @return Whether a {@link TrustedWebActivityService} was found.
*/
@MainThread
public boolean serviceExistsForScope(@NonNull Uri scope, @NonNull String origin) {
// If we have an existing connection, we can deal with the scope.
if (mConnections.get(scope) != null) return true;
return createServiceIntent(mContext, scope, origin, false) != null;
}
/**
* Unbinds all open connections to Trusted Web Activity clients.
*/
void unbindAllConnections() {
for (ConnectionHolder connection : mConnections.values()) {
mContext.unbindService(connection);
}
mConnections.clear();
}
/**
* Creates an Intent to launch the Service for the given scope and verified origin. Will
* return null if there is no applicable Service.
*/
private @Nullable Intent createServiceIntent(Context appContext, Uri scope, String origin,
boolean shouldLog) {
Set<String> possiblePackages = getVerifiedPackages(appContext, origin);
if (possiblePackages == null || possiblePackages.size() == 0) {
return null;
}
// Get a list of installed packages that would match the scope.
Intent scopeResolutionIntent = new Intent();
scopeResolutionIntent.setData(scope);
scopeResolutionIntent.setAction(Intent.ACTION_VIEW);
// TODO(peconn): Do we want MATCH_ALL here.
// TODO(peconn): Do we need a category here?
List<ResolveInfo> candidateActivities = appContext.getPackageManager()
.queryIntentActivities(scopeResolutionIntent, PackageManager.MATCH_DEFAULT_ONLY);
// Choose the first of the installed packages that is verified.
String resolvedPackage = null;
for (ResolveInfo info : candidateActivities) {
String packageName = info.activityInfo.packageName;
if (possiblePackages.contains(packageName)) {
resolvedPackage = packageName;
break;
}
}
if (resolvedPackage == null) {
if (shouldLog) Log.w(TAG, "No TWA candidates for " + origin + " have been registered.");
return null;
}
// Find the TrustedWebActivityService within that package.
Intent serviceResolutionIntent = new Intent();
serviceResolutionIntent.setPackage(resolvedPackage);
serviceResolutionIntent.setAction(
TrustedWebActivityService.ACTION_TRUSTED_WEB_ACTIVITY_SERVICE);
ResolveInfo info = appContext.getPackageManager().resolveService(serviceResolutionIntent,
PackageManager.MATCH_ALL);
if (info == null) {
if (shouldLog) Log.w(TAG, "Could not find TWAService for " + resolvedPackage);
return null;
}
if (shouldLog) {
Log.i(TAG, "Found " + info.serviceInfo.name + " to handle request for " + origin);
}
Intent finalIntent = new Intent();
finalIntent.setComponent(new ComponentName(resolvedPackage, info.serviceInfo.name));
return finalIntent;
}
/**
* Registers (and persists) a package to be used for an origin. This information is persisted
* in SharedPreferences. Although this method can be called on any thread, it may read
* SharedPreferences and hit the disk, so call it on a background thread if possible.
* @param context A Context to access SharedPreferences.
* @param origin The origin for which the package is relevant.
* @param clientPackage The packages to register.
*/
public static void registerClient(@NonNull Context context, @NonNull String origin,
@NonNull String clientPackage) {
Set<String> possiblePackages = getVerifiedPackages(context, origin);
possiblePackages.add(clientPackage);
// sSharedPreferences won't be null after a call to getVerifiedPackages.
SharedPreferences.Editor editor = sSharedPreferences.get().edit();
editor.putStringSet(origin, possiblePackages);
editor.apply();
}
}