1 /*
2  * Copyright (C) 2024 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.server.notification;
18 
19 import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
20 import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
21 
22 import android.annotation.FlaggedApi;
23 import android.annotation.NonNull;
24 import android.app.AlarmManager;
25 import android.app.PendingIntent;
26 import android.content.BroadcastReceiver;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.content.IntentFilter;
30 import android.net.Uri;
31 import android.os.SystemClock;
32 import android.util.Pair;
33 import android.util.Slog;
34 import com.android.internal.annotations.GuardedBy;
35 import com.android.internal.annotations.VisibleForTesting;
36 import com.android.server.pm.PackageManagerService;
37 
38 import java.io.PrintWriter;
39 import java.util.TreeSet;
40 
41 /**
42  * Handles canceling notifications when their time to live expires
43  */
44 @FlaggedApi(Flags.FLAG_ALL_NOTIFS_NEED_TTL)
45 public class TimeToLiveHelper {
46     private static final String TAG = TimeToLiveHelper.class.getSimpleName();
47     private static final String ACTION = "com.android.server.notification.TimeToLiveHelper";
48 
49     private static final int REQUEST_CODE_TIMEOUT = 1;
50     private static final String SCHEME_TIMEOUT = "timeout";
51     static final String EXTRA_KEY = "key";
52     private final Context mContext;
53     private final NotificationManagerPrivate mNm;
54     private final AlarmManager mAm;
55 
56     @VisibleForTesting
57     final TreeSet<Pair<Long, String>> mKeys;
58 
TimeToLiveHelper(NotificationManagerPrivate nm, Context context)59     public TimeToLiveHelper(NotificationManagerPrivate nm, Context context) {
60         mContext = context;
61         mNm = nm;
62         mAm = context.getSystemService(AlarmManager.class);
63         mKeys = new TreeSet<>((left, right) -> Long.compare(left.first, right.first));
64 
65         IntentFilter timeoutFilter = new IntentFilter(ACTION);
66         timeoutFilter.addDataScheme(SCHEME_TIMEOUT);
67         mContext.registerReceiver(mNotificationTimeoutReceiver, timeoutFilter,
68                 Context.RECEIVER_NOT_EXPORTED);
69     }
70 
destroy()71     void destroy() {
72         mContext.unregisterReceiver(mNotificationTimeoutReceiver);
73     }
74 
dump(PrintWriter pw, String indent)75     void dump(PrintWriter pw, String indent) {
76         pw.println(indent + "mKeys " + mKeys);
77     }
78 
getAlarmPendingIntent(String nextKey, int flags)79     private @NonNull PendingIntent getAlarmPendingIntent(String nextKey, int flags) {
80         flags |= PendingIntent.FLAG_IMMUTABLE;
81         return PendingIntent.getBroadcast(mContext,
82                 REQUEST_CODE_TIMEOUT,
83                 new Intent(ACTION)
84                         .setPackage(PackageManagerService.PLATFORM_PACKAGE_NAME)
85                         .setData(new Uri.Builder()
86                                 .scheme(SCHEME_TIMEOUT)
87                                 .appendPath(nextKey)
88                                 .build())
89                         .putExtra(EXTRA_KEY, nextKey)
90                         .addFlags(Intent.FLAG_RECEIVER_FOREGROUND),
91                 flags);
92     }
93 
94     @VisibleForTesting
scheduleTimeoutLocked(NotificationRecord record, long currentTime)95     void scheduleTimeoutLocked(NotificationRecord record, long currentTime) {
96         removeMatchingEntry(record.getKey());
97 
98         final long timeoutAfter = currentTime + record.getNotification().getTimeoutAfter();
99         if (record.getNotification().getTimeoutAfter() > 0) {
100             final Long currentEarliestTime = mKeys.isEmpty() ? null : mKeys.first().first;
101 
102             // Maybe replace alarm with an earlier one
103             if (currentEarliestTime == null || timeoutAfter < currentEarliestTime) {
104                 if (currentEarliestTime != null) {
105                     cancelFirstAlarm();
106                 }
107                 mKeys.add(Pair.create(timeoutAfter, record.getKey()));
108                 maybeScheduleFirstAlarm();
109             } else {
110                 mKeys.add(Pair.create(timeoutAfter, record.getKey()));
111             }
112         }
113     }
114 
115     @VisibleForTesting
cancelScheduledTimeoutLocked(NotificationRecord record)116     void cancelScheduledTimeoutLocked(NotificationRecord record) {
117         removeMatchingEntry(record.getKey());
118     }
119 
removeMatchingEntry(String key)120     private void removeMatchingEntry(String key) {
121         if (!mKeys.isEmpty() && key.equals(mKeys.first().second)) {
122             // cancel the first alarm, remove the first entry, maybe schedule the alarm for the new
123             // first entry
124             cancelFirstAlarm();
125             mKeys.remove(mKeys.first());
126             maybeScheduleFirstAlarm();
127         } else {
128             // just remove the entry
129             Pair<Long, String> trackedPair = null;
130             for (Pair<Long, String> entry : mKeys) {
131                 if (key.equals(entry.second)) {
132                     trackedPair = entry;
133                     break;
134                 }
135             }
136             if (trackedPair != null) {
137                 mKeys.remove(trackedPair);
138             }
139         }
140     }
141 
cancelFirstAlarm()142     private void cancelFirstAlarm() {
143         final PendingIntent pi = getAlarmPendingIntent(mKeys.first().second, FLAG_CANCEL_CURRENT);
144         mAm.cancel(pi);
145     }
146 
maybeScheduleFirstAlarm()147     private void maybeScheduleFirstAlarm() {
148         if (!mKeys.isEmpty()) {
149             final PendingIntent piNewFirst = getAlarmPendingIntent(mKeys.first().second,
150                     FLAG_UPDATE_CURRENT);
151             mAm.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
152                     mKeys.first().first, piNewFirst);
153         }
154     }
155 
156     @VisibleForTesting
157     final BroadcastReceiver mNotificationTimeoutReceiver = new BroadcastReceiver() {
158         @Override
159         public void onReceive(Context context, Intent intent) {
160             String action = intent.getAction();
161             if (action == null) {
162                 return;
163             }
164             if (ACTION.equals(action)) {
165                 Pair<Long, String> earliest = mKeys.first();
166                 String key = intent.getStringExtra(EXTRA_KEY);
167                 if (!earliest.second.equals(key)) {
168                     Slog.wtf(TAG, "Alarm triggered but wasn't the earliest we were tracking");
169                 }
170                 removeMatchingEntry(key);
171                 mNm.timeoutNotification(earliest.second);
172             }
173         }
174     };
175 }
176