TrustedWebActivityServiceConnectionPool.java

/*
 * 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;
    }
}