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 android.service.notification;
18 
19 import android.annotation.FlaggedApi;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.app.AutomaticZenRule;
23 import android.app.Flags;
24 import android.content.Context;
25 import android.service.notification.ZenModeConfig.EventInfo;
26 import android.service.notification.ZenModeConfig.ScheduleInfo;
27 import android.service.notification.ZenModeConfig.ZenRule;
28 import android.text.format.DateFormat;
29 import android.util.Log;
30 
31 import com.android.internal.R;
32 
33 import java.text.SimpleDateFormat;
34 import java.util.Calendar;
35 import java.util.Locale;
36 import java.util.Objects;
37 
38 /**
39  * Helper methods for schedule-type (system-owned) rules.
40  * @hide
41  */
42 public final class SystemZenRules {
43 
44     private static final String TAG = "SystemZenRules";
45 
46     public static final String PACKAGE_ANDROID = "android";
47 
48     /** Updates existing system-owned rules to use the new Modes fields (type, etc). */
49     @FlaggedApi(Flags.FLAG_MODES_API)
maybeUpgradeRules(Context context, ZenModeConfig config)50     public static void maybeUpgradeRules(Context context, ZenModeConfig config) {
51         for (ZenRule rule : config.automaticRules.values()) {
52             if (isSystemOwnedRule(rule)) {
53                 if (rule.type == AutomaticZenRule.TYPE_UNKNOWN) {
54                     upgradeSystemProviderRule(context, rule);
55                 }
56                 if (Flags.modesUi()) {
57                     rule.allowManualInvocation = true;
58                 }
59             }
60         }
61     }
62 
63     /**
64      * Returns whether the rule corresponds to a system ConditionProviderService (i.e. it is owned
65      * by the "android" package).
66      */
isSystemOwnedRule(ZenRule rule)67     public static boolean isSystemOwnedRule(ZenRule rule) {
68         return PACKAGE_ANDROID.equals(rule.pkg);
69     }
70 
71     @FlaggedApi(Flags.FLAG_MODES_API)
upgradeSystemProviderRule(Context context, ZenRule rule)72     private static void upgradeSystemProviderRule(Context context, ZenRule rule) {
73         ScheduleInfo scheduleInfo = ZenModeConfig.tryParseScheduleConditionId(rule.conditionId);
74         if (scheduleInfo != null) {
75             rule.type = AutomaticZenRule.TYPE_SCHEDULE_TIME;
76             rule.triggerDescription = getTriggerDescriptionForScheduleTime(context, scheduleInfo);
77             return;
78         }
79         EventInfo eventInfo = ZenModeConfig.tryParseEventConditionId(rule.conditionId);
80         if (eventInfo != null) {
81             rule.type = AutomaticZenRule.TYPE_SCHEDULE_CALENDAR;
82             rule.triggerDescription = getTriggerDescriptionForScheduleEvent(context, eventInfo);
83             return;
84         }
85         Log.wtf(TAG, "Couldn't determine type of system-owned ZenRule " + rule);
86     }
87 
88     /**
89      * Updates the {@link ZenRule#triggerDescription} of the system-owned rule based on the schedule
90      * or event condition encoded in its {@link ZenRule#conditionId}.
91      *
92      * @return {@code true} if the trigger description was updated.
93      */
updateTriggerDescription(Context context, ZenRule rule)94     public static boolean updateTriggerDescription(Context context, ZenRule rule) {
95         ScheduleInfo scheduleInfo = ZenModeConfig.tryParseScheduleConditionId(rule.conditionId);
96         if (scheduleInfo != null) {
97             return updateTriggerDescription(rule,
98                     getTriggerDescriptionForScheduleTime(context, scheduleInfo));
99         }
100         EventInfo eventInfo = ZenModeConfig.tryParseEventConditionId(rule.conditionId);
101         if (eventInfo != null) {
102             return updateTriggerDescription(rule,
103                     getTriggerDescriptionForScheduleEvent(context, eventInfo));
104         }
105         Log.wtf(TAG, "Couldn't determine type of system-owned ZenRule " + rule);
106         return false;
107     }
108 
updateTriggerDescription(ZenRule rule, String triggerDescription)109     private static boolean updateTriggerDescription(ZenRule rule, String triggerDescription) {
110         if (!Objects.equals(rule.triggerDescription, triggerDescription)) {
111             rule.triggerDescription = triggerDescription;
112             return true;
113         }
114         return false;
115     }
116 
117     /**
118      * Returns a suitable trigger description for a time-schedule rule (e.g. "Mon-Fri, 8:00-10:00"),
119      * using the Context's current locale.
120      */
121     @Nullable
getTriggerDescriptionForScheduleTime(Context context, @NonNull ScheduleInfo schedule)122     public static String getTriggerDescriptionForScheduleTime(Context context,
123             @NonNull ScheduleInfo schedule) {
124         final StringBuilder sb = new StringBuilder();
125         String daysSummary = getShortDaysSummary(context, schedule);
126         if (daysSummary == null) {
127             // no use outputting times without dates
128             return null;
129         }
130         sb.append(daysSummary);
131         sb.append(context.getString(R.string.zen_mode_trigger_summary_divider_text));
132         sb.append(context.getString(
133                 R.string.zen_mode_trigger_summary_range_symbol_combination,
134                 timeString(context, schedule.startHour, schedule.startMinute),
135                 timeString(context, schedule.endHour, schedule.endMinute)));
136 
137         return sb.toString();
138     }
139 
140     /**
141      * Returns an ordered summarized list of the days on which this schedule applies, with
142      * adjacent days grouped together ("Sun-Wed" instead of "Sun,Mon,Tue,Wed").
143      */
144     @Nullable
getShortDaysSummary(Context context, @NonNull ScheduleInfo schedule)145     private static String getShortDaysSummary(Context context, @NonNull ScheduleInfo schedule) {
146         // Compute a list of days with contiguous days grouped together, for example: "Sun-Thu" or
147         // "Sun-Mon,Wed,Fri"
148         final int[] days = schedule.days;
149         if (days != null && days.length > 0) {
150             final StringBuilder sb = new StringBuilder();
151             final Calendar cStart = Calendar.getInstance(getLocale(context));
152             final Calendar cEnd = Calendar.getInstance(getLocale(context));
153             int[] daysOfWeek = getDaysOfWeekForLocale(cStart);
154             // the i for loop goes through days in order as determined by locale. as we walk through
155             // the days of the week, keep track of "start" and "last seen"  as indicators for
156             // what's contiguous, and initialize them to something not near actual indices
157             int startDay = Integer.MIN_VALUE;
158             int lastSeenDay = Integer.MIN_VALUE;
159             for (int i = 0; i < daysOfWeek.length; i++) {
160                 final int day = daysOfWeek[i];
161 
162                 // by default, output if this day is *not* included in the schedule, and thus
163                 // ends a previously existing block. if this day is included in the schedule
164                 // after all (as will be determined in the inner for loop), then output will be set
165                 // to false.
166                 boolean output = (i == lastSeenDay + 1);
167                 for (int j = 0; j < days.length; j++) {
168                     if (day == days[j]) {
169                         // match for this day in the schedule (indicated by counter i)
170                         if (i == lastSeenDay + 1) {
171                             // contiguous to the block we're walking through right now, record it
172                             // (specifically, i, the day index) and move on to the next day
173                             lastSeenDay = i;
174                             output = false;
175                         } else {
176                             // it's a match, but not 1 past the last match, we are starting a new
177                             // block
178                             startDay = i;
179                             lastSeenDay = i;
180                         }
181 
182                         // if there is a match on the last day, also make sure to output at the end
183                         // of this loop, and mark the day as the last day we'll have seen in the
184                         // scheduled days.
185                         if (i == daysOfWeek.length - 1) {
186                             output = true;
187                         }
188                         break;
189                     }
190                 }
191 
192                 // output in either of 2 cases: this day is not a match, so has ended any previous
193                 // block, or this day *is* a match but is the last day of the week, so we need to
194                 // summarize
195                 if (output) {
196                     // either describe just the single day if startDay == lastSeenDay, or
197                     // output "startDay - lastSeenDay" as a group
198                     if (sb.length() > 0) {
199                         sb.append(
200                                 context.getString(R.string.zen_mode_trigger_summary_divider_text));
201                     }
202 
203                     SimpleDateFormat dayFormat = new SimpleDateFormat("EEE", getLocale(context));
204                     if (startDay == lastSeenDay) {
205                         // last group was only one day
206                         cStart.set(Calendar.DAY_OF_WEEK, daysOfWeek[startDay]);
207                         sb.append(dayFormat.format(cStart.getTime()));
208                     } else {
209                         // last group was a contiguous group of days, so group them together
210                         cStart.set(Calendar.DAY_OF_WEEK, daysOfWeek[startDay]);
211                         cEnd.set(Calendar.DAY_OF_WEEK, daysOfWeek[lastSeenDay]);
212                         sb.append(context.getString(
213                                 R.string.zen_mode_trigger_summary_range_symbol_combination,
214                                 dayFormat.format(cStart.getTime()),
215                                 dayFormat.format(cEnd.getTime())));
216                     }
217                 }
218             }
219 
220             if (sb.length() > 0) {
221                 return sb.toString();
222             }
223         }
224         return null;
225     }
226 
227     /**
228      * Convenience method for representing the specified time in string format.
229      */
timeString(Context context, int hour, int minute)230     private static String timeString(Context context, int hour, int minute) {
231         final Calendar c = Calendar.getInstance(getLocale(context));
232         c.set(Calendar.HOUR_OF_DAY, hour);
233         c.set(Calendar.MINUTE, minute);
234         return DateFormat.getTimeFormat(context).format(c.getTime());
235     }
236 
getDaysOfWeekForLocale(Calendar c)237     private static int[] getDaysOfWeekForLocale(Calendar c) {
238         int[] daysOfWeek = new int[7];
239         int currentDay = c.getFirstDayOfWeek();
240         for (int i = 0; i < daysOfWeek.length; i++) {
241             if (currentDay > 7) currentDay = 1;
242             daysOfWeek[i] = currentDay;
243             currentDay++;
244         }
245         return daysOfWeek;
246     }
247 
getLocale(Context context)248     private static Locale getLocale(Context context) {
249         return context.getResources().getConfiguration().getLocales().get(0);
250     }
251 
252     /**
253      * Returns a suitable trigger description for a calendar-schedule rule (either the name of the
254      * calendar, or a message indicating all calendars are included).
255      */
getTriggerDescriptionForScheduleEvent(Context context, @NonNull EventInfo event)256     public static String getTriggerDescriptionForScheduleEvent(Context context,
257             @NonNull EventInfo event) {
258         if (event.calName != null) {
259             return event.calName;
260         } else {
261             return context.getResources().getString(
262                     R.string.zen_mode_trigger_event_calendar_any);
263         }
264     }
265 
SystemZenRules()266     private SystemZenRules() {}
267 }
268