blob: 23b976638eef78effb51c7fa621b78f7562df5f9 [file] [log] [blame]
/*
* Copyright (C) 2022 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 com.android.carrierdefaultapp;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.drawable.Icon;
import android.os.LocaleList;
import android.os.SystemProperties;
import android.os.UserHandle;
import android.telephony.AnomalyReporter;
import android.telephony.SubscriptionManager;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.URLUtil;
import android.webkit.WebView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.phone.slice.SlicePurchaseController;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
* The SlicePurchaseBroadcastReceiver listens for
* {@link SlicePurchaseController#ACTION_START_SLICE_PURCHASE_APP} from the SlicePurchaseController
* in the phone process to start the slice purchase application. It displays the performance boost
* notification to the user and will start the {@link SlicePurchaseActivity} to display the
* {@link WebView} to purchase performance boosts from the user's carrier.
*/
public class SlicePurchaseBroadcastReceiver extends BroadcastReceiver{
private static final String TAG = "SlicePurchaseBroadcastReceiver";
/**
* UUID to report an anomaly when receiving a PendingIntent from an application or process
* other than the Phone process.
*/
private static final String UUID_BAD_PENDING_INTENT = "c360246e-95dc-4abf-9dc1-929a76cd7e53";
/** Channel ID for the performance boost notification. */
private static final String PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID = "performance_boost";
/** Tag for the performance boost notification. */
public static final String PERFORMANCE_BOOST_NOTIFICATION_TAG = "SlicePurchaseApp.Notification";
/**
* Action for when the user clicks the "Not now" button on the performance boost notification.
*/
private static final String ACTION_NOTIFICATION_CANCELED =
"com.android.phone.slice.action.NOTIFICATION_CANCELED";
/**
* A map of Intents sent by {@link SlicePurchaseController} for each capability.
* If this map contains an Intent for a given capability, the performance boost notification to
* purchase the capability is visible to the user.
* If this map does not contain an Intent for a given capability, either the capability was
* never requested or the {@link SlicePurchaseActivity} is visible to the user.
* An Intent is added to this map when the performance boost notification is displayed to the
* user and removed from the map when the notification is canceled.
*/
private static final Map<Integer, Intent> sIntents = new HashMap<>();
/**
* Cancel the performance boost notification for the given capability and
* remove the corresponding notification intent from the map.
*
* @param context The context to cancel the notification in.
* @param capability The premium capability to cancel the notification for.
*/
public static void cancelNotification(@NonNull Context context,
@TelephonyManager.PremiumCapability int capability) {
context.getSystemService(NotificationManager.class).cancelAsUser(
PERFORMANCE_BOOST_NOTIFICATION_TAG, capability, UserHandle.ALL);
sIntents.remove(capability);
}
/**
* Send the PendingIntent containing the corresponding slice purchase application response.
*
* @param intent The Intent containing the PendingIntent extra.
* @param extra The extra to get the PendingIntent to send.
*/
public static void sendSlicePurchaseAppResponse(@NonNull Intent intent, @NonNull String extra) {
PendingIntent pendingIntent = intent.getParcelableExtra(extra, PendingIntent.class);
if (pendingIntent == null) {
loge("PendingIntent does not exist for extra: " + extra);
return;
}
try {
pendingIntent.send();
} catch (PendingIntent.CanceledException e) {
loge("Unable to send " + getPendingIntentType(extra) + " intent: " + e);
}
}
/**
* Send the PendingIntent containing the corresponding slice purchase application response
* with additional data.
*
* @param context The Context to use to send the PendingIntent.
* @param intent The Intent containing the PendingIntent extra.
* @param extra The extra to get the PendingIntent to send.
* @param data The Intent containing additional data to send with the PendingIntent.
*/
public static void sendSlicePurchaseAppResponseWithData(@NonNull Context context,
@NonNull Intent intent, @NonNull String extra, @NonNull Intent data) {
PendingIntent pendingIntent = intent.getParcelableExtra(extra, PendingIntent.class);
if (pendingIntent == null) {
loge("PendingIntent does not exist for extra: " + extra);
return;
}
try {
pendingIntent.send(context, 0 /* unused */, data);
} catch (PendingIntent.CanceledException e) {
loge("Unable to send " + getPendingIntentType(extra) + " intent: " + e);
}
}
/**
* Check whether the Intent is valid and can be used to complete purchases in the slice purchase
* application. This checks that all necessary extras exist and that the values are valid.
*
* @param intent The intent to check.
* @return {@code true} if the intent is valid and {@code false} otherwise.
*/
public static boolean isIntentValid(@NonNull Intent intent) {
int phoneId = intent.getIntExtra(SlicePurchaseController.EXTRA_PHONE_ID,
SubscriptionManager.INVALID_PHONE_INDEX);
if (phoneId == SubscriptionManager.INVALID_PHONE_INDEX) {
loge("isIntentValid: invalid phone index: " + phoneId);
return false;
}
int subId = intent.getIntExtra(SlicePurchaseController.EXTRA_SUB_ID,
SubscriptionManager.INVALID_SUBSCRIPTION_ID);
if (subId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
loge("isIntentValid: invalid subscription ID: " + subId);
return false;
}
int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY,
SlicePurchaseController.PREMIUM_CAPABILITY_INVALID);
if (capability == SlicePurchaseController.PREMIUM_CAPABILITY_INVALID) {
loge("isIntentValid: invalid premium capability: " + capability);
return false;
}
String purchaseUrl = intent.getStringExtra(SlicePurchaseController.EXTRA_PURCHASE_URL);
if (getPurchaseUrl(purchaseUrl) == null) {
loge("isIntentValid: invalid purchase URL: " + purchaseUrl);
return false;
}
String carrier = intent.getStringExtra(SlicePurchaseController.EXTRA_CARRIER);
if (TextUtils.isEmpty(carrier)) {
loge("isIntentValid: empty carrier: " + carrier);
return false;
}
return isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_CANCELED)
&& isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_CARRIER_ERROR)
&& isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_REQUEST_FAILED)
&& isPendingIntentValid(intent,
SlicePurchaseController.EXTRA_INTENT_NOT_DEFAULT_DATA_SUBSCRIPTION)
&& isPendingIntentValid(intent, SlicePurchaseController.EXTRA_INTENT_SUCCESS)
&& isPendingIntentValid(intent,
SlicePurchaseController.EXTRA_INTENT_NOTIFICATION_SHOWN);
}
/**
* Get the {@link URL} from the given purchase URL String, if it is valid.
*
* @param purchaseUrl The purchase URL String to use to create the URL.
* @return The purchase URL from the given String or {@code null} if it is invalid.
*/
@Nullable public static URL getPurchaseUrl(@Nullable String purchaseUrl) {
if (!URLUtil.isValidUrl(purchaseUrl)) {
return null;
}
if (URLUtil.isAssetUrl(purchaseUrl)
&& !purchaseUrl.equals(SlicePurchaseController.SLICE_PURCHASE_TEST_FILE)) {
return null;
}
URL url = null;
try {
url = new URL(purchaseUrl);
url.toURI();
} catch (MalformedURLException | URISyntaxException e) {
loge("Invalid purchase URL: " + purchaseUrl + ", " + e);
}
return url;
}
private static boolean isPendingIntentValid(@NonNull Intent intent, @NonNull String extra) {
String intentType = getPendingIntentType(extra);
PendingIntent pendingIntent = intent.getParcelableExtra(extra, PendingIntent.class);
if (pendingIntent == null) {
loge("isPendingIntentValid: " + intentType + " intent not found.");
return false;
}
String creatorPackage = pendingIntent.getCreatorPackage();
if (!creatorPackage.equals(TelephonyManager.PHONE_PROCESS_NAME)) {
String logStr = "isPendingIntentValid: " + intentType + " intent was created by "
+ creatorPackage + " instead of the phone process.";
loge(logStr);
AnomalyReporter.reportAnomaly(UUID.fromString(UUID_BAD_PENDING_INTENT), logStr);
return false;
}
if (!pendingIntent.isBroadcast()) {
loge("isPendingIntentValid: " + intentType + " intent is not a broadcast.");
return false;
}
return true;
}
@NonNull private static String getPendingIntentType(@NonNull String extra) {
switch (extra) {
case SlicePurchaseController.EXTRA_INTENT_CANCELED: return "canceled";
case SlicePurchaseController.EXTRA_INTENT_CARRIER_ERROR: return "carrier error";
case SlicePurchaseController.EXTRA_INTENT_REQUEST_FAILED: return "request failed";
case SlicePurchaseController.EXTRA_INTENT_NOT_DEFAULT_DATA_SUBSCRIPTION:
return "not default data subscription";
case SlicePurchaseController.EXTRA_INTENT_SUCCESS: return "success";
case SlicePurchaseController.EXTRA_INTENT_NOTIFICATION_SHOWN:
return "notification shown";
default: {
loge("Unknown pending intent extra: " + extra);
return "unknown(" + extra + ")";
}
}
}
@Override
public void onReceive(@NonNull Context context, @NonNull Intent intent) {
logd("onReceive intent: " + intent.getAction());
switch (intent.getAction()) {
case Intent.ACTION_LOCALE_CHANGED:
onLocaleChanged(context);
break;
case SlicePurchaseController.ACTION_START_SLICE_PURCHASE_APP:
onDisplayPerformanceBoostNotification(context, intent, false);
break;
case SlicePurchaseController.ACTION_SLICE_PURCHASE_APP_RESPONSE_TIMEOUT:
onTimeout(context, intent);
break;
case ACTION_NOTIFICATION_CANCELED:
onUserCanceled(context, intent);
break;
default:
loge("Received unknown action: " + intent.getAction());
}
}
private void onLocaleChanged(@NonNull Context context) {
if (sIntents.isEmpty()) return;
for (int capability : new int[]{TelephonyManager.PREMIUM_CAPABILITY_PRIORITIZE_LATENCY}) {
if (sIntents.get(capability) != null) {
// Notification is active -- update notification for new locale
context.getSystemService(NotificationManager.class).cancelAsUser(
PERFORMANCE_BOOST_NOTIFICATION_TAG, capability, UserHandle.ALL);
onDisplayPerformanceBoostNotification(context, sIntents.get(capability), true);
}
}
}
private void onDisplayPerformanceBoostNotification(@NonNull Context context,
@NonNull Intent intent, boolean repeat) {
if (!repeat && !isIntentValid(intent)) {
sendSlicePurchaseAppResponse(intent,
SlicePurchaseController.EXTRA_INTENT_REQUEST_FAILED);
return;
}
Resources res = getResources(context);
NotificationChannel channel = new NotificationChannel(
PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID,
res.getString(R.string.performance_boost_notification_channel),
NotificationManager.IMPORTANCE_DEFAULT);
// CarrierDefaultApp notifications are unblockable by default. Make this channel blockable
// to allow users to disable notifications posted to this channel without affecting other
// notifications in this application.
channel.setBlockable(true);
context.getSystemService(NotificationManager.class).createNotificationChannel(channel);
String carrier = intent.getStringExtra(SlicePurchaseController.EXTRA_CARRIER);
Notification notification =
new Notification.Builder(context, PERFORMANCE_BOOST_NOTIFICATION_CHANNEL_ID)
.setContentTitle(res.getString(
R.string.performance_boost_notification_title))
.setContentText(String.format(res.getString(
R.string.performance_boost_notification_detail), carrier))
.setSmallIcon(R.drawable.ic_performance_boost)
.setContentIntent(createContentIntent(context, intent, 1))
.setDeleteIntent(intent.getParcelableExtra(
SlicePurchaseController.EXTRA_INTENT_CANCELED, PendingIntent.class))
// Add an action for the "Not now" button, which has the same behavior as
// the user canceling or closing the notification.
.addAction(new Notification.Action.Builder(
Icon.createWithResource(context, R.drawable.ic_performance_boost),
res.getString(
R.string.performance_boost_notification_button_not_now),
createCanceledIntent(context, intent)).build())
// Add an action for the "Manage" button, which has the same behavior as
// the user clicking on the notification.
.addAction(new Notification.Action.Builder(
Icon.createWithResource(context, R.drawable.ic_performance_boost),
res.getString(
R.string.performance_boost_notification_button_manage),
createContentIntent(context, intent, 2)).build())
.build();
int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY,
SlicePurchaseController.PREMIUM_CAPABILITY_INVALID);
logd((repeat ? "Update" : "Display") + " the performance boost notification for capability "
+ TelephonyManager.convertPremiumCapabilityToString(capability));
context.getSystemService(NotificationManager.class).notifyAsUser(
PERFORMANCE_BOOST_NOTIFICATION_TAG, capability, notification, UserHandle.ALL);
if (!repeat) {
sIntents.put(capability, intent);
sendSlicePurchaseAppResponse(intent,
SlicePurchaseController.EXTRA_INTENT_NOTIFICATION_SHOWN);
}
}
/**
* Get the {@link Resources} for the current locale.
*
* @param context The context to get the resources in.
*
* @return The resources in the current locale.
*/
@VisibleForTesting
@NonNull public Resources getResources(@NonNull Context context) {
Resources resources = context.getResources();
Configuration config = resources.getConfiguration();
config.setLocale(getCurrentLocale());
return new Resources(resources.getAssets(), resources.getDisplayMetrics(), config);
}
/**
* Get the current {@link Locale} from the system property {@code persist.sys.locale}.
*
* @return The user's default/preferred language.
*/
@VisibleForTesting
@NonNull public Locale getCurrentLocale() {
String languageTag = SystemProperties.get("persist.sys.locale");
if (TextUtils.isEmpty(languageTag)) {
return LocaleList.getAdjustedDefault().get(0);
}
return Locale.forLanguageTag(languageTag);
}
/**
* Create the intent for when the user clicks on the "Manage" button on the performance boost
* notification or the notification itself. This will open {@link SlicePurchaseActivity}.
*
* @param context The Context to create the intent for.
* @param intent The source Intent used to launch the slice purchase application.
* @param requestCode The request code for the PendingIntent.
*
* @return The intent to start {@link SlicePurchaseActivity}.
*/
@VisibleForTesting
@NonNull public PendingIntent createContentIntent(@NonNull Context context,
@NonNull Intent intent, int requestCode) {
Intent i = new Intent(context, SlicePurchaseActivity.class);
i.setComponent(ComponentName.unflattenFromString(
"com.android.carrierdefaultapp/.SlicePurchaseActivity"));
i.setFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT
| Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
i.putExtras(intent);
return PendingIntent.getActivityAsUser(context, requestCode, i,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, null /* options */,
UserHandle.CURRENT);
}
/**
* Create the canceled intent for when the user clicks the "Not now" button on the performance
* boost notification. This will send {@link #ACTION_NOTIFICATION_CANCELED} and has the same
* behavior as if the user had canceled or removed the notification.
*
* @param context The Context to create the intent for.
* @param intent The source Intent used to launch the slice purchase application.
*
* @return The canceled intent.
*/
@VisibleForTesting
@NonNull public PendingIntent createCanceledIntent(@NonNull Context context,
@NonNull Intent intent) {
Intent i = new Intent(ACTION_NOTIFICATION_CANCELED);
i.setComponent(ComponentName.unflattenFromString(
"com.android.carrierdefaultapp/.SlicePurchaseBroadcastReceiver"));
i.putExtras(intent);
return PendingIntent.getBroadcast(context, 0, i,
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
}
private void onTimeout(@NonNull Context context, @NonNull Intent intent) {
int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY,
SlicePurchaseController.PREMIUM_CAPABILITY_INVALID);
logd("Purchase capability " + TelephonyManager.convertPremiumCapabilityToString(capability)
+ " timed out.");
if (sIntents.get(capability) != null) {
// Notification is still active -- cancel pending notification
logd("Closing performance boost notification since the user did not respond in time.");
cancelNotification(context, capability);
} else {
// SlicePurchaseActivity is still active -- ignore timer
logd("Ignoring timeout since the SlicePurchaseActivity is still active.");
}
}
private void onUserCanceled(@NonNull Context context, @NonNull Intent intent) {
int capability = intent.getIntExtra(SlicePurchaseController.EXTRA_PREMIUM_CAPABILITY,
SlicePurchaseController.PREMIUM_CAPABILITY_INVALID);
logd("onUserCanceled: " + TelephonyManager.convertPremiumCapabilityToString(capability));
cancelNotification(context, capability);
sendSlicePurchaseAppResponse(intent, SlicePurchaseController.EXTRA_INTENT_CANCELED);
}
private static void logd(String s) {
Log.d(TAG, s);
}
private static void loge(String s) {
Log.e(TAG, s);
}
}