blob: 6e67822b35dd99b16c2b1d06ad9153c82e543238 [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.annotation.SuppressLint;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.Service;
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.ServiceInfo;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcelable;
import android.os.StrictMode;
import android.support.customtabs.trusted.ITrustedWebActivityService;
import android.support.annotation.BinderThread;
import android.support.annotation.CallSuper;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RestrictTo;
import androidx.browser.trusted.TrustedWebActivityServiceConnection.ActiveNotificationsArgs;
import androidx.browser.trusted.TrustedWebActivityServiceConnection.CancelNotificationArgs;
import androidx.browser.trusted.TrustedWebActivityServiceConnection.NotificationsEnabledArgs;
import androidx.browser.trusted.TrustedWebActivityServiceConnection.NotifyNotificationArgs;
import androidx.browser.trusted.TrustedWebActivityServiceConnection.ResultArgs;
import android.support.v4.app.NotificationManagerCompat;
import java.util.Arrays;
import java.util.Locale;
/**
* The TrustedWebActivityService lives in a client app and serves requests from a Trusted Web
* Activity provider. At present it only serves requests to do with notifications.
* <p>
* When the provider receives a notification from a scope that is associated with a Trusted Web
* Activity client app, it will attempt to connect to a TrustedWebActivityService and forward calls.
* This allows the client app to display the notifications itself, meaning it is attributable to the
* client app and is managed by notification permissions of the client app, not the provider.
* <p>
* TrustedWebActivityService is usable as it is, by adding the following to your AndroidManifest:
*
* <pre>
* <service
* android:name="androidx.browser.trusted.TrustedWebActivityService"
* android:enabled="true"
* android:exported="true">
*
* <meta-data android:name="android.support.customtabs.trusted.SMALL_ICON"
* android:resource="@drawable/ic_notification_icon" />
*
* <intent-filter>
* <action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/>
* <category android:name="android.intent.category.DEFAULT"/>
* </intent-filter>
* </service>
* </pre>
*
* The SMALL_ICON resource should point to a drawable to be used for the notification's small icon.
* <p>
* Alternatively for greater customization, TrustedWebActivityService can be extended and
* overridden. In this case the manifest entry should be updated to point to the extending class.
* <p>
* As this is an AIDL Service, calls may come in from different Binder threads, so overriding
* implementations need to be thread safe [1].
* <p>
* For security, the TrustedWebActivityService will check that whatever connects to it is the
* Trusted Web Activity provider that it was previously verified with (through
* {@link #setVerifiedProvider}).
* This is because we don't want to allow any app on the users device to connect to this Service
* be able to make it display notifications.
*
* [1]: https://developer.android.com/guide/components/aidl.html
*/
public class TrustedWebActivityService extends Service {
/** An Intent Action used by the provider to find the TrustedWebActivityService or subclass. */
@SuppressLint({
"ActionValue", // This value was being used before being moved into AndroidX.
"ServiceName", // This variable is an Action, but Metalava thinks it's a Service.
})
public static final String ACTION_TRUSTED_WEB_ACTIVITY_SERVICE =
"android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE";
/** The Android Manifest meta-data name to specify a small icon id to use. */
public static final String META_DATA_NAME_SMALL_ICON =
"android.support.customtabs.trusted.SMALL_ICON";
/**
* The key to use to store a Bitmap to return from the {@link #onGetSmallIconBitmap()} method.
*/
public static final String KEY_SMALL_ICON_BITMAP =
"android.support.customtabs.trusted.SMALL_ICON_BITMAP";
/** Used as a return value of {@link #onGetSmallIconId} when the icon is not provided. */
public static final int SMALL_ICON_NOT_SET = -1;
private static final String PREFS_FILE = "TrustedWebActivityVerifiedProvider";
private static final String PREFS_VERIFIED_PROVIDER = "Provider";
private NotificationManager mNotificationManager;
@SuppressWarnings("WeakerAccess") /* synthetic access */
int mVerifiedUid = -1;
private final ITrustedWebActivityService.Stub mBinder =
new ITrustedWebActivityService.Stub() {
@Override
public Bundle areNotificationsEnabled(Bundle bundle) {
checkCaller();
NotificationsEnabledArgs args = NotificationsEnabledArgs.fromBundle(bundle);
boolean result =
TrustedWebActivityService.this.onAreNotificationsEnabled(args.channelName);
return new ResultArgs(result).toBundle();
}
@Override
public Bundle notifyNotificationWithChannel(Bundle bundle) {
checkCaller();
NotifyNotificationArgs args = NotifyNotificationArgs.fromBundle(bundle);
boolean success = TrustedWebActivityService.this.onNotifyNotificationWithChannel(
args.platformTag, args.platformId, args.notification, args.channelName);
return new ResultArgs(success).toBundle();
}
@Override
public void cancelNotification(Bundle bundle) {
checkCaller();
CancelNotificationArgs args = CancelNotificationArgs.fromBundle(bundle);
TrustedWebActivityService.this.onCancelNotification(args.platformTag, args.platformId);
}
@Override
public Bundle getActiveNotifications() {
checkCaller();
return new ActiveNotificationsArgs(
TrustedWebActivityService.this.onGetActiveNotifications()).toBundle();
}
@Override
public int getSmallIconId() {
checkCaller();
return TrustedWebActivityService.this.onGetSmallIconId();
}
@Override
public Bundle getSmallIconBitmap() {
checkCaller();
return TrustedWebActivityService.this.onGetSmallIconBitmap();
}
private void checkCaller() {
if (mVerifiedUid == -1) {
String[] packages = getPackageManager().getPackagesForUid(getCallingUid());
// We need to read Preferences. This should only be called on the Binder thread
// which is designed to handle long running, blocking tasks, so disk I/O should be
// OK.
StrictMode.ThreadPolicy policy = StrictMode.allowThreadDiskReads();
try {
String verifiedPackage = getPreferences(TrustedWebActivityService.this)
.getString(PREFS_VERIFIED_PROVIDER, null);
if (Arrays.asList(packages).contains(verifiedPackage)) {
mVerifiedUid = getCallingUid();
return;
}
} finally {
StrictMode.setThreadPolicy(policy);
}
}
if (mVerifiedUid == getCallingUid()) return;
throw new SecurityException("Caller is not verified as Trusted Web Activity provider.");
}
};
/**
* Called by the system when the service is first created. Do not call this method directly.
* Overrides must call {@code super.onCreate()}.
*/
@Override
@CallSuper
@MainThread
public void onCreate() {
super.onCreate();
mNotificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
}
/**
* Checks whether notifications are enabled.
* @param channelName The name of the notification channel to be used on Android O+.
* @return Whether notifications are enabled.
*/
@BinderThread
public boolean onAreNotificationsEnabled(@NonNull String channelName) {
ensureOnCreateCalled();
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) return false;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true;
return NotificationApiHelperForO.isChannelEnabled(mNotificationManager,
channelNameToId(channelName));
}
/**
* Displays a notification.
* @param platformTag The notification tag, see
* {@link NotificationManager#notify(String, int, Notification)}.
* @param platformId The notification id, see
* {@link NotificationManager#notify(String, int, Notification)}.
* @param notification The notification to be displayed, constructed by the provider.
* @param channelName The name of the notification channel that the notification should be
* displayed on. This method gets or creates a channel from the name and
* modifies the notification to use that channel.
* @return Whether the notification was successfully displayed (the channel/app may be blocked
* by the user).
*/
@BinderThread
public boolean onNotifyNotificationWithChannel(@NonNull String platformTag, int platformId,
@NonNull Notification notification, @NonNull String channelName) {
ensureOnCreateCalled();
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) return false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
String channelId = channelNameToId(channelName);
notification = NotificationApiHelperForO.copyNotificationOntoChannel(this,
mNotificationManager, notification, channelId, channelName);
if (!NotificationApiHelperForO.isChannelEnabled(mNotificationManager, channelId)) {
return false;
}
}
mNotificationManager.notify(platformTag, platformId, notification);
return true;
}
/**
* Cancels a notification.
* @param platformTag The notification tag, see
* {@link NotificationManager#cancel(String, int)}.
* @param platformId The notification id, see
* {@link NotificationManager#cancel(String, int)}.
*/
@BinderThread
public void onCancelNotification(@NonNull String platformTag, int platformId) {
ensureOnCreateCalled();
mNotificationManager.cancel(platformTag, platformId);
}
/**
* Returns a list of active notifications, essentially calling
* NotificationManager#getActiveNotifications. The default implementation does not work on
* pre-Android M.
* @return An array of StatusBarNotifications as Parcelables.
*
* @hide
*/
@NonNull
@BinderThread
@RestrictTo(RestrictTo.Scope.LIBRARY)
public Parcelable[] onGetActiveNotifications() {
ensureOnCreateCalled();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return NotificationApiHelperForM.getActiveNotifications(mNotificationManager);
}
throw new IllegalStateException("onGetActiveNotifications cannot be called pre-M.");
}
/**
* Returns a Bundle containing a bitmap to be use as the small icon for any notifications.
* @return A Bundle that may contain a Bitmap contained with key {@link #KEY_SMALL_ICON_BITMAP}.
* The bundle may be empty if the client app does not provide a small icon.
*/
@BinderThread
public @NonNull Bundle onGetSmallIconBitmap() {
int id = onGetSmallIconId();
Bundle bundle = new Bundle();
if (id == SMALL_ICON_NOT_SET) {
return bundle;
}
bundle.putParcelable(KEY_SMALL_ICON_BITMAP,
BitmapFactory.decodeResource(getResources(), id));
return bundle;
}
/**
* Returns the Android resource id of a drawable to be used for the small icon of the
* notification. This is called by the provider as it is constructing the notification so a
* complete notification can be passed to the client.
*
* Default behaviour looks for meta-data with the name {@link #META_DATA_NAME_SMALL_ICON} in
* service section of the manifest.
* @return A resource id for the small icon, or {@link #SMALL_ICON_NOT_SET} if not found.
*/
@BinderThread
public int onGetSmallIconId() {
try {
ServiceInfo info = getPackageManager().getServiceInfo(
new ComponentName(this, getClass()), PackageManager.GET_META_DATA);
if (info.metaData == null) return SMALL_ICON_NOT_SET;
return info.metaData.getInt(META_DATA_NAME_SMALL_ICON, SMALL_ICON_NOT_SET);
} catch (PackageManager.NameNotFoundException e) {
// Will only happen if the package provided (the one we are running in) is not
// installed - so should never happen.
return SMALL_ICON_NOT_SET;
}
}
@Override
@Nullable
@MainThread
public final IBinder onBind(@Nullable Intent intent) {
return mBinder;
}
@Override
@MainThread
public final boolean onUnbind(@Nullable Intent intent) {
mVerifiedUid = -1;
return super.onUnbind(intent);
}
/**
* Should *not* be called on UI Thread, as accessing Preferences may hit disk.
*/
@SuppressWarnings("WeakerAccess") /* synthetic access */
static SharedPreferences getPreferences(Context context) {
return context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE);
}
/**
* Sets (asynchronously) the package that this service will accept connections from.
* @param context A context to be used to access SharedPreferences.
* @param provider The package of the provider to accept connections from or null to clear.
*/
public static final void setVerifiedProvider(final @NonNull Context context,
@Nullable String provider) {
final String providerEmptyChecked =
(provider == null || provider.isEmpty()) ? null : provider;
// Perform on a background thread as accessing Preferences may cause disk access.
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
SharedPreferences.Editor editor = getPreferences(context).edit();
editor.putString(PREFS_VERIFIED_PROVIDER, providerEmptyChecked);
editor.apply();
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* See {@link #setVerifiedProvider}, the main difference being that this approach sets the
* provider synchronously, so may trigger a disk read.
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public static final void setVerifiedProviderSynchronouslyForTesting(@NonNull Context context,
@Nullable String provider) {
String providerEmptyChecked = (provider == null || provider.isEmpty()) ? null : provider;
StrictMode.ThreadPolicy policy = StrictMode.allowThreadDiskReads();
try {
SharedPreferences.Editor editor = getPreferences(context).edit();
editor.putString(PREFS_VERIFIED_PROVIDER, providerEmptyChecked);
editor.apply();
} finally {
StrictMode.setThreadPolicy(policy);
}
}
private static String channelNameToId(String name) {
return name.toLowerCase(Locale.ROOT).replace(' ', '_') + "_channel_id";
}
private void ensureOnCreateCalled() {
if (mNotificationManager != null) return;
throw new IllegalStateException("TrustedWebActivityService has not been properly "
+ "initialized. Did onCreate() call super.onCreate()?");
}
}