blob: a7438ef5e24353ab3eb873cc014f363868763dbd [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.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
/**
* A TrustedWebActivityServiceConnectionPool will be used by a Trusted Web Activity provider and
* takes care of connecting to and communicating with {@link TrustedWebActivityService}s.
* This is done through the {@link #connect} method.
* <p>
* Multiple Trusted Web Activity client apps may be suitable for a given scope.
* These are passed in to {@link #connect} and {@link #serviceExistsForScope} and the most
* appropriate one for the scope is chosen.
*/
public final class TrustedWebActivityServiceConnectionPool {
private static final String TAG = "TWAConnectionPool";
/** Application context, used to connect to the services. */
private final Context mContext;
/** Map from ServiceWorker scope to Connection. */
private final Map<Uri, ConnectionHolder> mConnections = new HashMap<>();
private TrustedWebActivityServiceConnectionPool(@NonNull Context context) {
mContext = context.getApplicationContext();
}
/**
* 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 to be contained in the {@code possiblePackages} set 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 possiblePackages A collection of packages to consider.
* These would be the packages that have previously launched a
* Trusted Web Activity for the origin.
* @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}).
* 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
@SuppressWarnings("deprecation") /* AsyncTask */
public ListenableFuture<TrustedWebActivityServiceConnection> connect(
@NonNull final Uri scope,
@NonNull Set<Token> possiblePackages,
@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, possiblePackages, 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();
}
@SuppressWarnings("deprecation") /* AsyncTask */
static class BindToServiceAsyncTask extends android.os.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
| Context.BIND_INCLUDE_CAPABILITIES)) {
return null;
}
mAppContext.unbindService(mConnection);
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.
* This method uses the same logic as {@link #connect}.
* If this method returns {@code false}, {@link #connect} will return a Future containing an
* {@link IllegalStateException}.
* <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 possiblePackages A collection of packages to consider.
* These would be the packages that have previously launched a
* Trusted Web Activity for the origin.
* @return Whether a {@link TrustedWebActivityService} was found.
*/
@MainThread
public boolean serviceExistsForScope(@NonNull Uri scope,
@NonNull Set<Token> possiblePackages) {
// If we have an existing connection, we can deal with the scope.
if (mConnections.get(scope) != null) return true;
return createServiceIntent(mContext, scope, possiblePackages, 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 to an app contained in
* {@code possiblePackages}.
* Will return {@code null} if there is no applicable Service.
*/
private @Nullable Intent createServiceIntent(Context appContext, Uri scope,
Set<Token> possiblePackages, boolean shouldLog) {
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);
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;
for (Token possiblePackage : possiblePackages) {
if (possiblePackage.matches(packageName, appContext.getPackageManager())) {
resolvedPackage = packageName;
break;
}
}
}
if (resolvedPackage == null) {
if (shouldLog) Log.w(TAG, "No TWA candidates for " + scope + " 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 " + scope);
}
Intent finalIntent = new Intent();
finalIntent.setComponent(new ComponentName(resolvedPackage, info.serviceInfo.name));
return finalIntent;
}
}