1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.safetycenter.notifications;
18 
19 import static android.safetycenter.SafetyCenterManager.EXTRA_SAFETY_SOURCE_ID;
20 import static android.safetycenter.SafetyCenterManager.EXTRA_SAFETY_SOURCE_ISSUE_ID;
21 import static android.safetycenter.SafetyCenterManager.EXTRA_SAFETY_SOURCE_USER_HANDLE;
22 
23 import static com.android.safetycenter.notifications.SafetyCenterNotificationChannels.getContextAsUser;
24 
25 import android.annotation.ColorInt;
26 import android.annotation.UserIdInt;
27 import android.app.Notification;
28 import android.app.NotificationChannel;
29 import android.app.NotificationManager;
30 import android.app.PendingIntent;
31 import android.content.Context;
32 import android.content.Intent;
33 import android.graphics.drawable.Icon;
34 import android.os.Bundle;
35 import android.os.UserHandle;
36 import android.safetycenter.SafetySourceData;
37 import android.safetycenter.SafetySourceIssue;
38 import android.text.TextUtils;
39 
40 import androidx.annotation.Nullable;
41 
42 import com.android.modules.utils.build.SdkLevel;
43 import com.android.safetycenter.PendingIntentFactory;
44 import com.android.safetycenter.internaldata.SafetyCenterIds;
45 import com.android.safetycenter.internaldata.SafetyCenterIssueActionId;
46 import com.android.safetycenter.internaldata.SafetyCenterIssueKey;
47 import com.android.safetycenter.resources.SafetyCenterResourcesApk;
48 
49 import java.time.Duration;
50 import java.util.List;
51 
52 /**
53  * Factory that builds {@link Notification} objects from {@link SafetySourceIssue} instances with
54  * appropriate {@link PendingIntent}s for click and dismiss callbacks.
55  */
56 final class SafetyCenterNotificationFactory {
57 
58     private static final int OPEN_SAFETY_CENTER_REQUEST_CODE = 1221;
59     private static final Duration SUCCESS_NOTIFICATION_TIMEOUT = Duration.ofSeconds(10);
60 
61     private final Context mContext;
62     private final SafetyCenterNotificationChannels mNotificationChannels;
63     private final SafetyCenterResourcesApk mSafetyCenterResourcesApk;
64 
SafetyCenterNotificationFactory( Context context, SafetyCenterNotificationChannels notificationChannels, SafetyCenterResourcesApk safetyCenterResourcesApk)65     SafetyCenterNotificationFactory(
66             Context context,
67             SafetyCenterNotificationChannels notificationChannels,
68             SafetyCenterResourcesApk safetyCenterResourcesApk) {
69         mContext = context;
70         mNotificationChannels = notificationChannels;
71         mSafetyCenterResourcesApk = safetyCenterResourcesApk;
72     }
73 
74     /**
75      * Creates and returns a new {@link Notification} for a successful action, or {@code null} if
76      * none could be created.
77      *
78      * <p>The provided {@link NotificationManager} is used to create or update the {@link
79      * NotificationChannel} for the notification.
80      */
81     @Nullable
newNotificationForSuccessfulAction( NotificationManager notificationManager, SafetySourceIssue issue, SafetySourceIssue.Action action, @UserIdInt int userId)82     Notification newNotificationForSuccessfulAction(
83             NotificationManager notificationManager,
84             SafetySourceIssue issue,
85             SafetySourceIssue.Action action,
86             @UserIdInt int userId) {
87         if (action.getSuccessMessage() == null) {
88             return null;
89         }
90 
91         String channelId = mNotificationChannels.getCreatedChannelId(notificationManager, issue);
92         if (channelId == null) {
93             return null;
94         }
95 
96         PendingIntent contentIntent = newSafetyCenterPendingIntent(userId);
97         if (contentIntent == null) {
98             return null;
99         }
100 
101         Notification.Builder builder =
102                 new Notification.Builder(mContext, channelId)
103                         .setSmallIcon(
104                                 getNotificationIcon(SafetySourceData.SEVERITY_LEVEL_INFORMATION))
105                         .setExtras(getNotificationExtras())
106                         .setContentTitle(action.getSuccessMessage())
107                         .setShowWhen(true)
108                         .setTimeoutAfter(SUCCESS_NOTIFICATION_TIMEOUT.toMillis())
109                         .setContentIntent(contentIntent)
110                         .setAutoCancel(true);
111 
112         Integer color = getNotificationColor(SafetySourceData.SEVERITY_LEVEL_INFORMATION);
113         if (color != null) {
114             builder.setColor(color);
115         }
116 
117         return builder.build();
118     }
119 
120     /**
121      * Creates and returns a new {@link Notification} instance corresponding to the given issue, or
122      * {@code null} if none could be created.
123      *
124      * <p>The provided {@link NotificationManager} is used to create or update the {@link
125      * NotificationChannel} for the notification.
126      */
127     @Nullable
newNotificationForIssue( NotificationManager notificationManager, SafetySourceIssue issue, SafetyCenterIssueKey issueKey)128     Notification newNotificationForIssue(
129             NotificationManager notificationManager,
130             SafetySourceIssue issue,
131             SafetyCenterIssueKey issueKey) {
132         String channelId = mNotificationChannels.getCreatedChannelId(notificationManager, issue);
133         if (channelId == null) {
134             return null;
135         }
136 
137         CharSequence title = issue.getTitle();
138         CharSequence text = issue.getSummary();
139         List<SafetySourceIssue.Action> issueActions = issue.getActions();
140 
141         if (SdkLevel.isAtLeastU()) {
142             SafetySourceIssue.Notification customNotification = issue.getCustomNotification();
143             if (customNotification != null) {
144                 title = customNotification.getTitle();
145                 text = customNotification.getText();
146                 issueActions = customNotification.getActions();
147             }
148         }
149 
150         PendingIntent contentIntent = newSafetyCenterPendingIntent(issueKey);
151         if (contentIntent == null) {
152             return null;
153         }
154 
155         Notification.Builder builder =
156                 new Notification.Builder(mContext, channelId)
157                         .setSmallIcon(getNotificationIcon(issue.getSeverityLevel()))
158                         .setExtras(getNotificationExtras())
159                         .setShowWhen(true)
160                         .setContentTitle(title)
161                         .setContentText(text)
162                         .setContentIntent(contentIntent)
163                         .setDeleteIntent(
164                                 SafetyCenterNotificationReceiver.newNotificationDismissedIntent(
165                                         mContext, issueKey));
166 
167         Integer color = getNotificationColor(issue.getSeverityLevel());
168         if (color != null) {
169             builder.setColor(color);
170         }
171 
172         for (int i = 0; i < issueActions.size(); i++) {
173             Notification.Action notificationAction =
174                     toNotificationAction(issueKey, issueActions.get(i));
175             builder.addAction(notificationAction);
176         }
177 
178         if (issue.getSeverityLevel() == SafetySourceData.SEVERITY_LEVEL_INFORMATION) {
179             builder.setAutoCancel(true);
180         }
181 
182         return builder.build();
183     }
184 
185     /**
186      * Returns a {@link PendingIntent} to open Safety Center, navigating to a specific issue, or
187      * {@code null} if no such intent can be created.
188      */
189     @Nullable
newSafetyCenterPendingIntent(SafetyCenterIssueKey issueKey)190     private PendingIntent newSafetyCenterPendingIntent(SafetyCenterIssueKey issueKey) {
191         UserHandle userHandle = UserHandle.of(issueKey.getUserId());
192         Context userContext = getContextAsUser(mContext, userHandle);
193         if (userContext == null) {
194             return null;
195         }
196 
197         Intent intent = newSafetyCenterIntent();
198         // Set the encoded issue key as the intent's identifier to ensure the PendingIntents of
199         // different notifications do not collide:
200         intent.setIdentifier(SafetyCenterIds.encodeToString(issueKey));
201         intent.putExtra(EXTRA_SAFETY_SOURCE_ID, issueKey.getSafetySourceId());
202         intent.putExtra(EXTRA_SAFETY_SOURCE_ISSUE_ID, issueKey.getSafetySourceIssueId());
203         intent.putExtra(EXTRA_SAFETY_SOURCE_USER_HANDLE, userHandle);
204 
205         return PendingIntentFactory.getActivityPendingIntent(
206                 userContext, OPEN_SAFETY_CENTER_REQUEST_CODE, intent, PendingIntent.FLAG_IMMUTABLE);
207     }
208 
209     /**
210      * Returns a {@link PendingIntent} to open Safety Center, or {@code null} if no such intent can
211      * be created.
212      */
213     @Nullable
newSafetyCenterPendingIntent(@serIdInt int userId)214     private PendingIntent newSafetyCenterPendingIntent(@UserIdInt int userId) {
215         Context userContext = getContextAsUser(mContext, UserHandle.of(userId));
216         if (userContext == null) {
217             return null;
218         }
219         return PendingIntentFactory.getActivityPendingIntent(
220                 userContext,
221                 OPEN_SAFETY_CENTER_REQUEST_CODE,
222                 newSafetyCenterIntent(),
223                 PendingIntent.FLAG_IMMUTABLE);
224     }
225 
newSafetyCenterIntent()226     private static Intent newSafetyCenterIntent() {
227         Intent intent = new Intent(Intent.ACTION_SAFETY_CENTER);
228         // This extra is defined in the PermissionController APK, cannot be referenced directly:
229         intent.putExtra("navigation_source_intent_extra", "NOTIFICATION");
230         return intent;
231     }
232 
getNotificationIcon(@afetySourceData.SeverityLevel int severityLevel)233     private Icon getNotificationIcon(@SafetySourceData.SeverityLevel int severityLevel) {
234         String iconResName = "ic_notification_badge_general";
235         if (severityLevel == SafetySourceData.SEVERITY_LEVEL_CRITICAL_WARNING) {
236             iconResName = "ic_notification_badge_critical";
237         }
238         Icon icon = mSafetyCenterResourcesApk.getIconByDrawableName(iconResName);
239         if (icon == null) {
240             // In case it was impossible to fetch the above drawable for any reason use this
241             // fallback which should be present on all Android devices:
242             icon = Icon.createWithResource(mContext, android.R.drawable.ic_dialog_alert);
243         }
244         return icon;
245     }
246 
247     @ColorInt
248     @Nullable
getNotificationColor(@afetySourceData.SeverityLevel int severityLevel)249     private Integer getNotificationColor(@SafetySourceData.SeverityLevel int severityLevel) {
250         String colorResName = "notification_tint_normal";
251         if (severityLevel == SafetySourceData.SEVERITY_LEVEL_CRITICAL_WARNING) {
252             colorResName = "notification_tint_critical";
253         }
254         return mSafetyCenterResourcesApk.getColorByName(colorResName);
255     }
256 
getNotificationExtras()257     private Bundle getNotificationExtras() {
258         Bundle extras = new Bundle();
259         String appName =
260                 mSafetyCenterResourcesApk.getStringByName("notification_channel_group_name");
261         if (!TextUtils.isEmpty(appName)) {
262             extras.putString(Notification.EXTRA_SUBSTITUTE_APP_NAME, appName);
263         }
264         return extras;
265     }
266 
toNotificationAction( SafetyCenterIssueKey issueKey, SafetySourceIssue.Action issueAction)267     private Notification.Action toNotificationAction(
268             SafetyCenterIssueKey issueKey, SafetySourceIssue.Action issueAction) {
269         PendingIntent pendingIntent = getPendingIntentForAction(issueKey, issueAction);
270         return new Notification.Action.Builder(
271                         /* icon= */ null, issueAction.getLabel(), pendingIntent)
272                 .build();
273     }
274 
getPendingIntentForAction( SafetyCenterIssueKey issueKey, SafetySourceIssue.Action issueAction)275     private PendingIntent getPendingIntentForAction(
276             SafetyCenterIssueKey issueKey, SafetySourceIssue.Action issueAction) {
277         if (issueAction.willResolve()) {
278             return getReceiverPendingIntentForResolvingAction(issueKey, issueAction);
279         } else {
280             return getDirectPendingIntentForNonResolvingAction(issueAction);
281         }
282     }
283 
getReceiverPendingIntentForResolvingAction( SafetyCenterIssueKey issueKey, SafetySourceIssue.Action issueAction)284     private PendingIntent getReceiverPendingIntentForResolvingAction(
285             SafetyCenterIssueKey issueKey, SafetySourceIssue.Action issueAction) {
286         // We do not use the action's PendingIntent directly here instead we build a new PI which
287         // will be handled by our SafetyCenterNotificationReceiver which will in turn dispatch
288         // the source-provided action PI. This ensures that action execution is consistent across
289         // between Safety Center UI and notifications, for example executing an action from a
290         // notification will send an "action in-flight" update to any current listeners.
291         SafetyCenterIssueActionId issueActionId =
292                 SafetyCenterIssueActionId.newBuilder()
293                         .setSafetyCenterIssueKey(issueKey)
294                         .setSafetySourceIssueActionId(issueAction.getId())
295                         .build();
296         return SafetyCenterNotificationReceiver.newNotificationActionClickedIntent(
297                 mContext, issueActionId);
298     }
299 
getDirectPendingIntentForNonResolvingAction( SafetySourceIssue.Action issueAction)300     private PendingIntent getDirectPendingIntentForNonResolvingAction(
301             SafetySourceIssue.Action issueAction) {
302         return issueAction.getPendingIntent();
303     }
304 }
305