TrustedWebActivityServiceConnectionManager.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.annotation.SuppressLint;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
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.IBinder;
import android.os.RemoteException;
import android.os.StrictMode;
import android.os.TransactionTooLargeException;
import android.support.customtabs.trusted.ITrustedWebActivityService;
import android.util.Log;

import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;

/**
 * A TrustedWebActivityServiceConnectionManager 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 #execute}.
 */
public class TrustedWebActivityServiceConnectionManager {
    private static final String TAG = "TWAConnectionManager";
    private static final String PREFS_FILE = "TrustedWebActivityVerifiedPackages";

    /**
     * A callback to be executed once a connection to a {@link TrustedWebActivityService} is open.
     */
    public interface ExecutionCallback {
        /**
         * Is run when a connection is open. See {@link #execute} for more information.
         * @param service A {@link TrustedWebActivityServiceWrapper} wrapping the connected
         *                {@link TrustedWebActivityService}.
         *                It may be null if the connection failed.
         * @throws RemoteException May be thrown by {@link TrustedWebActivityServiceWrapper}'s
         *                         methods. If the developer does not want to catch them, they will
         *                         be caught gracefully by {@link #execute}.
         */
        @SuppressLint("RethrowRemoteException")  // We're accepting RemoteExceptions not throwing.
        void onConnected(@Nullable TrustedWebActivityServiceWrapper service) throws RemoteException;
    }

    /** The callback used internally that will wrap an ExecutionCallback. */
    private interface WrappedCallback {
        void onConnected(@Nullable TrustedWebActivityServiceWrapper service);
    }

    /**
     * Holds a connection to a TrustedWebActivityService.
     * It should only be used on the UI Thread.
     */
    private class Connection implements ServiceConnection {
        private TrustedWebActivityServiceWrapper mService;
        private List<WrappedCallback> mCallbacks = new LinkedList<>();
        private final Uri mScope;

        Connection(Uri scope) {
            mScope = scope;
        }

        public TrustedWebActivityServiceWrapper getService() {
            return mService;
        }

        /** This method will be called on the UI Thread by the Android Framework. */
        @Override
        public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
            mService = new TrustedWebActivityServiceWrapper(
                    ITrustedWebActivityService.Stub.asInterface(iBinder), componentName);
            for (WrappedCallback callback : mCallbacks) {
                callback.onConnected(mService);
            }
            mCallbacks.clear();
        }

        /** This method will be called on the UI Thread by the Android Framework. */
        @Override
        public void onServiceDisconnected(ComponentName componentName) {
            mService = null;
            mConnections.remove(mScope);
        }

        public void addCallback(WrappedCallback callback) {
            if (mService == null) {
                mCallbacks.add(callback);
            } else {
                callback.onConnected(mService);
            }
        }
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    final Context mContext;

    /** Map from ServiceWorker scope to Connection. */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    Map<Uri, Connection> 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 #execute} 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));
        }
    }

    /**
     * Creates a TrustedWebActivityServiceConnectionManager.
     * @param context A Context used for accessing SharedPreferences.
     */
    public TrustedWebActivityServiceConnectionManager(@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);
            }
        });
    }

    private static WrappedCallback wrapCallback(final ExecutionCallback callback) {
        return new WrappedCallback() {
            @Override
            public void onConnected(@Nullable final TrustedWebActivityServiceWrapper service) {
                AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            callback.onConnected(service);
                        } catch (TransactionTooLargeException e) {
                            Log.w(TAG,
                                    "TransactionTooLargeException from TrustedWebActivityService, "
                                            + "possibly due to large size of small icon.", e);
                        } catch (RemoteException | RuntimeException e) {
                            Log.w(TAG,
                                    "Exception while trying to use TrustedWebActivityService.", e);
                        }
                    }
                });
            }
        };
    }

    /**
     * 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 callback A {@link ExecutionCallback} that will be run with a connection.
     *                 It will be run on a background thread from the ThreadPool as most methods
     *                 from {@link TrustedWebActivityServiceWrapper} require this.
     *                 Any {@link RemoteException} or {@link RuntimeException} exceptions thrown by
     *                 the callback will be swallowed.
     *                 This is to allow users to deal with exceptions thrown by
     *                 {@link TrustedWebActivityServiceWrapper} if they wish, but to fail
     *                 gracefully if they don't.
     * @return Whether a {@link TrustedWebActivityService} was found.
     */
    @SuppressLint("StaticFieldLeak")
    @MainThread
    public boolean execute(@NonNull final Uri scope, @NonNull String origin,
            @NonNull final ExecutionCallback callback) {
        final WrappedCallback wrappedCallback = wrapCallback(callback);

        // If we have an existing connection, use it.
        Connection connection = mConnections.get(scope);
        if (connection != null) {
            connection.addCallback(wrappedCallback);
            return true;
        }

        // Check that this is a notification we want to handle.
        final Intent bindServiceIntent = createServiceIntent(mContext, scope, origin, true);
        if (bindServiceIntent == null) return false;

        final Connection newConnection = new Connection(scope);
        newConnection.addCallback(wrappedCallback);

        // Create a new connection.
        new AsyncTask<Void, Void, Connection>() {
            @Override
            protected Connection 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 (mContext.bindService(bindServiceIntent, newConnection,
                            Context.BIND_AUTO_CREATE)) {
                        return newConnection;
                    }

                    mContext.unbindService(newConnection);
                    return null;
                } catch (SecurityException e) {
                    Log.w(TAG, "SecurityException while binding.", e);
                    return null;
                }
            }

            @Override
            protected void onPostExecute(Connection newConnection) {
                if (newConnection == null) {
                    wrappedCallback.onConnected(null);
                } else {
                    mConnections.put(scope, newConnection);
                }
            }
        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);

        return true;
    }

    /**
     * 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 #execute} 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 (Connection 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();
    }
}