CarPendingIntent.java

/*
 * Copyright 2021 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.car.app.notification;

import static androidx.car.app.utils.CommonUtils.isAutomotiveOS;

import static java.util.Objects.requireNonNull;

import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.car.app.CarContext;

import java.security.InvalidParameterException;
import java.util.List;
import java.util.Objects;

/**
 * A class which creates {@link PendingIntent}s that will start a car app, to be used in a
 * notification action.
 */
public final class CarPendingIntent {
    @VisibleForTesting
    static final String CAR_APP_ACTIVITY_CLASSNAME = "androidx.car.app.activity.CarAppActivity";

    /**
     * The key for retrieving the original {@link Intent} form the one the OS sent from the user
     * click.
     */
    static final String COMPONENT_EXTRA_KEY =
            "androidx.car.app.notification.COMPONENT_EXTRA_KEY";

    private static final String NAVIGATION_URI_PREFIX = "geo:";
    private static final String PHONE_URI_PREFIX = "tel:";
    private static final String SEARCH_QUERY_PARAMETER = "q";
    private static final String SEARCH_QUERY_PARAMETER_SPLITTER = SEARCH_QUERY_PARAMETER + "=";

    // TODO(b/185173683): Update to PendingIntent.FLAG_MUTABLE once available (Android S)
    private static final int FLAG_MUTABLE = 1 << 25;

    /**
     * Creates a {@link PendingIntent} that can be sent in a notification action which will allow
     * the targeted car app to be started when the user clicks on the action.
     *
     * <p>See {@link CarContext#startCarApp} for the supported intents that can be passed to this
     * method.
     *
     * <p>Here is an example of usage of this method when setting a notification's intent:
     *
     * <pre>
     *     NotificationCompat.Builder builder;
     *     ...
     *     builder.setContentIntent(CarPendingIntent.getCarApp(getCarContext(), 0,
     *             new Intent(Intent.ACTION_VIEW).setComponent(
     *                     new ComponentName(getCarContext(), MyCarAppService.class)), 0));
     * </pre>
     *
     * @param context     the context in which this PendingIntent should use to start the car app
     * @param requestCode private request code for the sender
     * @param intent      the intent that will be sent to the car app
     * @param flags       may be any of the flags allowed by
     *                    {@link PendingIntent#getBroadcast(Context, int, Intent, int)} except for
     *                    {@link PendingIntent#FLAG_IMMUTABLE} as the {@link PendingIntent} needs
     *                    to be mutable to allow the host to add the necessary extras for
     *                    starting the car app. If {@link PendingIntent#FLAG_IMMUTABLE} is set,
     *                    it will be unset before creating the {@link PendingIntent}
     * @throws NullPointerException      if either {@code context} or {@code intent} are null
     * @throws InvalidParameterException if the {@code intent} is not for starting a navigation
     *                                   or a phone call and does not have the target car app's
     *                                   component name
     * @throws SecurityException         if the {@code intent} is for a different component than the
     *                                   one associated with the input {@code context}
     *
     * @return an existing or new PendingIntent matching the given parameters. May return {@code
     * null} only if {@link PendingIntent#FLAG_NO_CREATE} has been supplied.
     */
    @NonNull
    public static PendingIntent getCarApp(@NonNull Context context, int requestCode,
            @NonNull Intent intent, int flags) {
        requireNonNull(context);
        requireNonNull(intent);

        validateIntent(context, intent);

        flags &= ~PendingIntent.FLAG_IMMUTABLE;
        flags |= FLAG_MUTABLE;

        if (isAutomotiveOS(context)) {
            return createForAutomotive(context, requestCode, intent, flags);
        } else {
            return createForProjected(context, requestCode, intent, flags);
        }
    }

    /**
     * Ensures that the {@link Intent} provided is valid for starting a car app.
     *
     * @see CarContext#startCarApp(Intent)
     */
    @VisibleForTesting
    static void validateIntent(Context context, Intent intent) {
        String packageName = context.getPackageName();
        String action = intent.getAction();
        ComponentName intentComponent = intent.getComponent();
        if (intentComponent != null && Objects.equals(intentComponent.getPackageName(),
                packageName)) {
            try {
                context.getPackageManager().getServiceInfo(intentComponent,
                        PackageManager.GET_META_DATA);
            } catch (PackageManager.NameNotFoundException e) {
                throw new InvalidParameterException("Intent does not have the CarAppService's "
                        + "ComponentName as its target" + intent);
            }
        } else if (Objects.equals(action, CarContext.ACTION_NAVIGATE)) {
            validateNavigationIntentIsValid(intent);
        } else if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_CALL.equals(action)) {
            validatePhoneIntentIsValid(intent);
        } else if (intentComponent == null) {
            throw new InvalidParameterException("The intent is not for a supported action");
        } else {
            throw new SecurityException("Explicitly starting a separate app is not supported");
        }
    }

    private static PendingIntent createForProjected(Context context, int requestCode, Intent intent,
            int flags) {
        intent.putExtra(COMPONENT_EXTRA_KEY, intent.getComponent());
        intent.setClass(context, CarAppNotificationBroadcastReceiver.class);

        return PendingIntent.getBroadcast(context, requestCode, intent, flags);
    }

    private static PendingIntent createForAutomotive(Context context, int requestCode,
            Intent intent, int flags) {
        String packageName = context.getPackageName();
        ComponentName intentComponent = intent.getComponent();
        if (intentComponent != null && Objects.equals(intentComponent.getPackageName(),
                packageName)) {
            intent.setClassName(packageName, CAR_APP_ACTIVITY_CLASSNAME);
        }

        return PendingIntent.getActivity(context, requestCode, intent, flags);
    }

    /**
     * Checks that the {@link Intent} is for a phone call by validating it meets the following:
     *
     * <ul>
     *   <li>The data is correctly formatted starting with 'tel:'
     *   <li>Has no component name set
     * </ul>
     */
    private static void validatePhoneIntentIsValid(Intent intent) {
        String data = intent.getDataString() == null ? "" : intent.getDataString();
        if (!data.startsWith(PHONE_URI_PREFIX)) {
            throw new InvalidParameterException("Phone intent data is not properly formatted");
        }

        if (intent.getComponent() != null) {
            throw new SecurityException("Phone intent cannot have a component");
        }
    }

    /**
     * Checks that the {@link Intent} is for navigation by validating it meets the following:
     *
     * <ul>
     *   <li>The data is formatted as described in {@link CarContext#startCarApp(Intent)}
     *   <li>Has no component name set
     * </ul>
     */
    private static void validateNavigationIntentIsValid(Intent intent) {
        String data = intent.getDataString() == null ? "" : intent.getDataString();
        if (!data.startsWith(NAVIGATION_URI_PREFIX)) {
            throw new InvalidParameterException("Navigation intent has a malformed uri");
        }

        Uri uri = intent.getData();
        if (getQueryString(uri) == null) {
            if (!isLatitudeLongitude(uri.getEncodedSchemeSpecificPart())) {
                throw new InvalidParameterException(
                        "Navigation intent has neither a location nor a query string");
            }
        }
    }

    /**
     * Returns whether the {@code possibleLatitudeLongitude} has a latitude longitude.
     */
    @SuppressWarnings("StringSplitter")
    private static boolean isLatitudeLongitude(String possibleLatitudeLongitude) {
        String[] parts = possibleLatitudeLongitude.split(",");
        if (parts.length == 2) {
            try {
                // Ensure both parts are doubles.
                Double.parseDouble(parts[0]);
                Double.parseDouble(parts[1]);
                return true;
            } catch (NumberFormatException e) {
                // Values are not Doubles.
            }
        }
        return false;
    }

    /**
     * Returns the actual query from the {@link Uri}, or {@code null} if none exists.
     *
     * <p>The query will be after 'q='.
     *
     * <p>For example if Uri string is 'geo:0,0?q=124+Foo+St', return value will be '124+Foo+St'.
     */
    @SuppressWarnings("StringSplitter")
    @Nullable
    private static String getQueryString(Uri uri) {
        if (uri.isHierarchical()) {
            List<String> queries = uri.getQueryParameters(SEARCH_QUERY_PARAMETER);
            return queries.isEmpty() ? null : queries.get(0);
        }

        String schemeSpecificPart = uri.getEncodedSchemeSpecificPart();
        String[] parts = schemeSpecificPart.split(SEARCH_QUERY_PARAMETER_SPLITTER);

        // If we have a valid split on "q=" split on "&" to only get the one parameter.
        return parts.length < 2 ? null : parts[1].split("&")[0];
    }

    private CarPendingIntent() {
    }
}