Add schedule setting page for time-based modes.

Creates new layout for setting the start & end time, and days of the week, for a schedule-based mode.

This is close to the mocks specified in https://screenshot.googleplex.com/8zmb7PAjjt73VkN, but with the following differences:
- the end time is left-aligned with the center rather than on the right side of the screen. This is a side effect of using LinearLayout to evenly space start & end times, and could in theory be fixed by using a ConstraintLayout, but that option seems to cause times to overlap instead of wrap when display size is cranked up. Could be fixed later.
- no icons yet on either side of the time display
- no Done button. Instead, has the "exit at alarm" switch that exists today.

Have not yet checked how this interacts with TalkBack, etc.

Flag: android.app.modes_ui
Bug: 332730302
Test: ZenModeSetSchedulePreferenceControllerTest, ZenModeExitAtAlarmPreferenceControllerTest, ZenModeSetTriggerLinkPreferenceControllerTest
Test: manual: interacting with UI in normal size, with font & display at minimum and maximum, and in locales (fr) where the first day of the week is a different day
Change-Id: I0b76f55891d6c12fc27720657c9eea6fe42fbafe
This commit is contained in:
Yuri Lin
2024-05-28 17:28:13 -04:00
parent cfaedb0199
commit 4203f311dd
13 changed files with 1129 additions and 3 deletions

View File

@@ -0,0 +1,27 @@
<!--
~ Copyright (C) 2024 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.
-->
<selector
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- when checked, background color will be accent color -->
<item
android:state_checked="true"
android:color="?android:attr/textColorPrimaryInverse" />
<!-- when unchecked, background color will be transparent -->
<item
android:state_checked="false"
android:color="?android:attr/colorAccent" />
</selector>

View File

@@ -0,0 +1,47 @@
<!--
~ Copyright (C) 2024 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.
-->
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android" >
<item
android:top="2dp"
android:bottom="2dp"
android:left="2dp"
android:right="2dp">
<selector>
<!-- selected state = solid filled in circle -->
<item android:state_checked="true">
<shape android:shape="oval"
android:tint="?android:attr/colorAccent">
<size android:height="34dp"
android:width="34dp" />
<solid android:color="@android:color/white" />
</shape>
</item>
<!-- unselected state = just the outline of a circle -->
<item android:state_checked="false">
<shape android:shape="oval">
<size android:height="34dp"
android:width="34dp" />
<stroke android:width="2dp"
android:color="?android:attr/colorAccent" />
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
</item>
</layer-list>

View File

@@ -0,0 +1,228 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2024 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.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/modes_set_schedule_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:gravity="fill_horizontal"
android:orientation="vertical"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="24dp"
android:paddingBottom="24dp">
<!-- Start time & end time row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="fill_horizontal"
android:orientation="horizontal">
<!-- Start time: title (non-clickable preference), time setter -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/start_time_label"
android:clickable="false"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Medium"
android:text="@string/zen_mode_start_time" />
<TextView
android:id="@+id/start_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Title"
android:textColor="?android:attr/colorAccent"
android:textSize="40sp" />
</LinearLayout>
<!-- End time: title (non-clickable preference), time setter -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/end_time_label"
android:clickable="false"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Medium"
android:text="@string/zen_mode_end_time" />
<TextView
android:id="@+id/end_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Title"
android:textColor="?android:attr/colorAccent"
android:textSize="40sp" />
</LinearLayout>
</LinearLayout>
<!-- Schedule duration display row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<!-- left side line divider -->
<View
android:layout_width="0dp"
android:layout_height="1.5dp"
android:layout_weight="1"
android:layout_gravity="center_vertical"
android:background="?android:attr/dividerHorizontal" />
<!-- length of schedule -->
<TextView
android:id="@+id/schedule_duration"
android:clickable="false"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Small" />
<!-- right side line divider -->
<View
android:layout_width="0dp"
android:layout_height="1.5dp"
android:layout_weight="1"
android:layout_gravity="center_vertical"
android:background="?android:attr/dividerHorizontal" />
</LinearLayout>
<!-- Buttons for selecting days of the week -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/days_of_week_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="10dp"
android:maxHeight="60dp"
android:orientation="horizontal">
<ToggleButton
android:id="@+id/day0"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintEnd_toStartOf="@+id/day1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ToggleButton
android:id="@+id/day1"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/day0"
app:layout_constraintEnd_toStartOf="@+id/day2"
app:layout_constraintTop_toTopOf="parent" />
<ToggleButton
android:id="@+id/day2"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/day1"
app:layout_constraintEnd_toStartOf="@+id/day3"
app:layout_constraintTop_toTopOf="parent" />
<ToggleButton
android:id="@+id/day3"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/day2"
app:layout_constraintEnd_toStartOf="@+id/day4"
app:layout_constraintTop_toTopOf="parent" />
<ToggleButton
android:id="@+id/day4"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/day3"
app:layout_constraintEnd_toStartOf="@+id/day5"
app:layout_constraintTop_toTopOf="parent" />
<ToggleButton
android:id="@+id/day5"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/day4"
app:layout_constraintEnd_toStartOf="@+id/day6"
app:layout_constraintTop_toTopOf="parent" />
<ToggleButton
android:id="@+id/day6"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/modes_schedule_day_toggle"
android:textColor="@color/modes_set_schedule_text_color"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/day5"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@@ -7961,6 +7961,15 @@
<!-- Do not disturb: Title on the page where users choose a calendar to determine the schedule for an automatically-triggered DND rule. [CHAR LIMIT=30] --> <!-- Do not disturb: Title on the page where users choose a calendar to determine the schedule for an automatically-triggered DND rule. [CHAR LIMIT=30] -->
<string name="zen_mode_set_calendar_category_title">Schedule</string> <string name="zen_mode_set_calendar_category_title">Schedule</string>
<!-- Do not disturb: Title prompting a user to set a time-based schedule to use for an automatic rule [CHAR LIMIT=30] -->
<string name="zen_mode_set_schedule_title">Set a schedule</string>
<!-- Do not disturb: Link text prompting a user to click through to setting a time-based schedule [CHAR LIMIT=40] -->
<string name="zen_mode_set_schedule_link">Schedule</string>
<!-- Duration in hours and minutes for the length of a Do Not Disturb schedule. For example "1 hr, 22 min" -->
<string name="zen_mode_schedule_duration"><xliff:g example="10" id="hours">%1$d</xliff:g> hr, <xliff:g example="20" id="minutes">%2$d</xliff:g> min</string>
<!-- Do not disturb: Title do not disturb settings representing automatic (scheduled) do not disturb rules. [CHAR LIMIT=30] --> <!-- Do not disturb: Title do not disturb settings representing automatic (scheduled) do not disturb rules. [CHAR LIMIT=30] -->
<string name="zen_mode_schedule_category_title">Schedule</string> <string name="zen_mode_schedule_category_title">Schedule</string>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2024 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.
-->
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:settings="http://schemas.android.com/apk/res-auto"
android:key="zen_mode_set_schedule"
settings:searchable="false"
android:title="@string/zen_mode_set_schedule_title">
<!-- Time picker for schedule -->
<com.android.settingslib.widget.LayoutPreference
android:key="schedule"
android:selectable="false"
android:layout="@layout/modes_set_schedule_layout"/>
<!-- Exit mode with alarm -->
<SwitchPreferenceCompat
android:key="exit_at_alarm"
android:title="@string/zen_mode_schedule_alarm_title"
android:summary="@string/zen_mode_schedule_alarm_summary"
android:order="99" />
</PreferenceScreen>

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2024 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.settings.notification.modes;
import android.content.Context;
import android.service.notification.ZenModeConfig;
import androidx.annotation.NonNull;
import androidx.preference.Preference;
import androidx.preference.TwoStatePreference;
/**
* Preference controller controlling whether a time schedule-based mode ends at the next alarm.
*/
class ZenModeExitAtAlarmPreferenceController extends
AbstractZenModePreferenceController implements Preference.OnPreferenceChangeListener {
private ZenModeConfig.ScheduleInfo mSchedule;
ZenModeExitAtAlarmPreferenceController(Context context,
String key, ZenModesBackend backend) {
super(context, key, backend);
}
@Override
public void updateState(Preference preference, @NonNull ZenMode zenMode) {
mSchedule = ZenModeConfig.tryParseScheduleConditionId(zenMode.getRule().getConditionId());
((TwoStatePreference) preference).setChecked(mSchedule.exitAtAlarm);
}
@Override
public boolean onPreferenceChange(@NonNull Preference preference, Object newValue) {
final boolean exitAtAlarm = (Boolean) newValue;
if (mSchedule.exitAtAlarm != exitAtAlarm) {
mSchedule.exitAtAlarm = exitAtAlarm;
return saveMode(mode -> {
mode.getRule().setConditionId(ZenModeConfig.toScheduleConditionId(mSchedule));
return mode;
});
}
return false;
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2024 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.settings.notification.modes;
import android.app.settings.SettingsEnums;
import android.content.Context;
import com.android.settings.R;
import com.android.settingslib.core.AbstractPreferenceController;
import java.util.ArrayList;
import java.util.List;
/**
* Settings page to set a schedule for a mode that turns on automatically based on specific days
* of the week and times of day.
*/
public class ZenModeSetScheduleFragment extends ZenModeFragmentBase {
@Override
protected int getPreferenceScreenResId() {
return R.xml.modes_set_schedule;
}
@Override
protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
List<AbstractPreferenceController> controllers = new ArrayList<>();
controllers.add(
new ZenModeSetSchedulePreferenceController(mContext, this, "schedule", mBackend));
controllers.add(
new ZenModeExitAtAlarmPreferenceController(mContext, "exit_at_alarm", mBackend));
return controllers;
}
@Override
public int getMetricsCategory() {
// TODO: b/332937635 - make this the correct metrics category
return SettingsEnums.NOTIFICATION_ZEN_MODE_SCHEDULE_RULE;
}
}

View File

@@ -0,0 +1,274 @@
/*
* Copyright (C) 2024 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.settings.notification.modes;
import android.app.Flags;
import android.content.Context;
import android.service.notification.SystemZenRules;
import android.service.notification.ZenModeConfig;
import android.text.format.DateFormat;
import android.util.ArraySet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.Fragment;
import androidx.preference.Preference;
import com.android.settings.R;
import com.android.settingslib.widget.LayoutPreference;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Arrays;
import java.util.Calendar;
import java.util.function.Function;
/**
* Preference controller for setting the start and end time and days of the week associated with
* an automatic zen mode.
*/
class ZenModeSetSchedulePreferenceController extends AbstractZenModePreferenceController {
// per-instance to ensure we're always using the current locale
// E = day of the week; "EEEEE" is the shortest version; "EEEE" is the full name
private final SimpleDateFormat mShortDayFormat = new SimpleDateFormat("EEEEE");
private final SimpleDateFormat mLongDayFormat = new SimpleDateFormat("EEEE");
private static final String TAG = "ZenModeSetSchedulePreferenceController";
private Fragment mParent;
private ZenModeConfig.ScheduleInfo mSchedule;
ZenModeSetSchedulePreferenceController(Context context, Fragment parent, String key,
ZenModesBackend backend) {
super(context, key, backend);
mParent = parent;
}
@Override
public void updateState(Preference preference, @NonNull ZenMode zenMode) {
mSchedule = ZenModeConfig.tryParseScheduleConditionId(zenMode.getRule().getConditionId());
LayoutPreference layoutPref = (LayoutPreference) preference;
TextView start = layoutPref.findViewById(R.id.start_time);
start.setText(timeString(mSchedule.startHour, mSchedule.startMinute));
start.setOnClickListener(
timePickerLauncher(mSchedule.startHour, mSchedule.startMinute, mStartSetter));
TextView end = layoutPref.findViewById(R.id.end_time);
end.setText(timeString(mSchedule.endHour, mSchedule.endMinute));
end.setOnClickListener(
timePickerLauncher(mSchedule.endHour, mSchedule.endMinute, mEndSetter));
TextView durationView = layoutPref.findViewById(R.id.schedule_duration);
durationView.setText(getScheduleDurationDescription(mSchedule));
ViewGroup daysContainer = layoutPref.findViewById(R.id.days_of_week_container);
setupDayToggles(daysContainer, mSchedule, Calendar.getInstance());
}
private String timeString(int hour, int minute) {
final Calendar c = Calendar.getInstance();
c.set(Calendar.HOUR_OF_DAY, hour);
c.set(Calendar.MINUTE, minute);
return DateFormat.getTimeFormat(mContext).format(c.getTime());
}
private boolean isValidTime(int hour, int minute) {
return ZenModeConfig.isValidHour(hour) && ZenModeConfig.isValidMinute(minute);
}
private String getScheduleDurationDescription(ZenModeConfig.ScheduleInfo schedule) {
final int startMin = 60 * schedule.startHour + schedule.startMinute;
final int endMin = 60 * schedule.endHour + schedule.endMinute;
final boolean nextDay = startMin >= endMin;
Duration scheduleDuration;
if (nextDay) {
// add one day's worth of minutes (24h x 60min) to end minute for end time calculation
int endMinNextDay = endMin + (24 * 60);
scheduleDuration = Duration.ofMinutes(endMinNextDay - startMin);
} else {
scheduleDuration = Duration.ofMinutes(endMin - startMin);
}
int hours = scheduleDuration.toHoursPart();
int minutes = scheduleDuration.minusHours(hours).toMinutesPart();
return mContext.getString(R.string.zen_mode_schedule_duration, hours, minutes);
}
@VisibleForTesting
protected Function<ZenMode, ZenMode> updateScheduleMode(ZenModeConfig.ScheduleInfo schedule) {
return (zenMode) -> {
zenMode.getRule().setConditionId(ZenModeConfig.toScheduleConditionId(schedule));
if (Flags.modesApi() && Flags.modesUi()) {
zenMode.getRule().setTriggerDescription(
SystemZenRules.getTriggerDescriptionForScheduleTime(mContext, schedule));
}
return zenMode;
};
}
private ZenModeTimePickerFragment.TimeSetter mStartSetter = (hour, minute) -> {
if (!isValidTime(hour, minute)) {
return;
}
if (hour == mSchedule.startHour && minute == mSchedule.startMinute) {
return;
}
mSchedule.startHour = hour;
mSchedule.startMinute = minute;
saveMode(updateScheduleMode(mSchedule));
};
private ZenModeTimePickerFragment.TimeSetter mEndSetter = (hour, minute) -> {
if (!isValidTime(hour, minute)) {
return;
}
if (hour == mSchedule.endHour && minute == mSchedule.endMinute) {
return;
}
mSchedule.endHour = hour;
mSchedule.endMinute = minute;
saveMode(updateScheduleMode(mSchedule));
};
private View.OnClickListener timePickerLauncher(int hour, int minute,
ZenModeTimePickerFragment.TimeSetter timeSetter) {
return v -> {
final ZenModeTimePickerFragment frag = new ZenModeTimePickerFragment(mContext, hour,
minute, timeSetter);
frag.show(mParent.getParentFragmentManager(), TAG);
};
}
protected static int[] getDaysOfWeekForLocale(Calendar c) {
int[] daysOfWeek = new int[7];
int currentDay = c.getFirstDayOfWeek();
for (int i = 0; i < daysOfWeek.length; i++) {
if (currentDay > 7) currentDay = 1;
daysOfWeek[i] = currentDay;
currentDay++;
}
return daysOfWeek;
}
@VisibleForTesting
protected void setupDayToggles(ViewGroup dayContainer, ZenModeConfig.ScheduleInfo schedule,
Calendar c) {
int[] daysOfWeek = getDaysOfWeekForLocale(c);
// Index in daysOfWeek is associated with the [idx]'th object in the list of days in the
// layout. Note that because the order of the days of the week may differ per locale, this
// is not necessarily the same as the actual value of the day number at that index.
for (int i = 0; i < daysOfWeek.length; i++) {
ToggleButton dayToggle = dayContainer.findViewById(resIdForDayIndex(i));
if (dayToggle == null) {
continue;
}
final int day = daysOfWeek[i];
c.set(Calendar.DAY_OF_WEEK, day);
// find current setting for this day
boolean dayEnabled = false;
if (schedule.days != null) {
for (int idx = 0; idx < schedule.days.length; idx++) {
if (schedule.days[idx] == day) {
dayEnabled = true;
break;
}
}
}
// On/off is indicated by visuals, and both states share the shortest (one-character)
// day label.
dayToggle.setTextOn(mShortDayFormat.format(c.getTime()));
dayToggle.setTextOff(mShortDayFormat.format(c.getTime()));
dayToggle.setContentDescription(mLongDayFormat.format(c.getTime()));
dayToggle.setChecked(dayEnabled);
dayToggle.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (updateScheduleDays(schedule, day, isChecked)) {
saveMode(updateScheduleMode(schedule));
}
});
// If display and text settings cause the text to be larger than its containing box,
// don't show scrollbars.
dayToggle.setVerticalScrollBarEnabled(false);
dayToggle.setHorizontalScrollBarEnabled(false);
}
}
// Updates the set of enabled days in provided schedule to either turn on or off the given day.
// The format of days in ZenModeConfig.ScheduleInfo is an array of days, where inclusion means
// the schedule is set to run on that day. Returns whether anything was changed.
@VisibleForTesting
protected static boolean updateScheduleDays(ZenModeConfig.ScheduleInfo schedule, int day,
boolean set) {
// Build a set representing the days that are currently set in mSchedule.
ArraySet<Integer> daySet = new ArraySet();
if (schedule.days != null) {
for (int i = 0; i < schedule.days.length; i++) {
daySet.add(schedule.days[i]);
}
}
if (daySet.contains(day) != set) {
if (set) {
daySet.add(day);
} else {
daySet.remove(day);
}
// rebuild days array for mSchedule
final int[] out = new int[daySet.size()];
for (int i = 0; i < daySet.size(); i++) {
out[i] = daySet.valueAt(i);
}
Arrays.sort(out);
schedule.days = out;
return true;
}
// If the setting is the same as it was before, no need to update anything.
return false;
}
protected static int resIdForDayIndex(int idx) {
switch (idx) {
case 0:
return R.id.day0;
case 1:
return R.id.day1;
case 2:
return R.id.day2;
case 3:
return R.id.day3;
case 4:
return R.id.day4;
case 5:
return R.id.day5;
case 6:
return R.id.day6;
default:
return 0; // unknown
}
}
}

View File

@@ -16,6 +16,7 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import static android.app.AutomaticZenRule.TYPE_SCHEDULE_CALENDAR; import static android.app.AutomaticZenRule.TYPE_SCHEDULE_CALENDAR;
import static android.app.AutomaticZenRule.TYPE_SCHEDULE_TIME;
import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_ID; import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_ID;
@@ -32,13 +33,13 @@ import com.android.settings.core.SubSettingLauncher;
import com.android.settingslib.PrimarySwitchPreference; import com.android.settingslib.PrimarySwitchPreference;
/** /**
* Preference controller for the link * Preference controller for the link to an individual mode's configuration page.
*/ */
public class ZenModeSetTriggerLinkPreferenceController extends AbstractZenModePreferenceController { class ZenModeSetTriggerLinkPreferenceController extends AbstractZenModePreferenceController {
@VisibleForTesting @VisibleForTesting
protected static final String AUTOMATIC_TRIGGER_PREF_KEY = "zen_automatic_trigger_settings"; protected static final String AUTOMATIC_TRIGGER_PREF_KEY = "zen_automatic_trigger_settings";
public ZenModeSetTriggerLinkPreferenceController(Context context, String key, ZenModeSetTriggerLinkPreferenceController(Context context, String key,
ZenModesBackend backend) { ZenModesBackend backend) {
super(context, key, backend); super(context, key, backend);
} }
@@ -66,6 +67,16 @@ public class ZenModeSetTriggerLinkPreferenceController extends AbstractZenModePr
// TODO: b/341961712 - direct preference to app-owned intent if available // TODO: b/341961712 - direct preference to app-owned intent if available
switch (zenMode.getRule().getType()) { switch (zenMode.getRule().getType()) {
case TYPE_SCHEDULE_TIME:
switchPref.setTitle(R.string.zen_mode_set_schedule_link);
switchPref.setSummary(zenMode.getRule().getTriggerDescription());
switchPref.setIntent(new SubSettingLauncher(mContext)
.setDestination(ZenModeSetScheduleFragment.class.getName())
// TODO: b/332937635 - set correct metrics category
.setSourceMetricsCategory(0)
.setArguments(bundle)
.toIntent());
break;
case TYPE_SCHEDULE_CALENDAR: case TYPE_SCHEDULE_CALENDAR:
switchPref.setTitle(R.string.zen_mode_set_calendar_link); switchPref.setTitle(R.string.zen_mode_set_calendar_link);
switchPref.setSummary(zenMode.getRule().getTriggerDescription()); switchPref.setSummary(zenMode.getRule().getTriggerDescription());

View File

@@ -0,0 +1,76 @@
/*
* Copyright (C) 2024 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.settings.notification.modes;
import android.app.Dialog;
import android.app.TimePickerDialog;
import android.app.settings.SettingsEnums;
import android.content.Context;
import android.os.Bundle;
import android.text.format.DateFormat;
import android.widget.TimePicker;
import androidx.annotation.NonNull;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
/**
* Dialog that shows when a user selects a (start or end) time to edit for a schedule-based mode.
*/
public class ZenModeTimePickerFragment extends InstrumentedDialogFragment implements
TimePickerDialog.OnTimeSetListener {
private final Context mContext;
private final TimeSetter mTimeSetter;
private final int mHour;
private final int mMinute;
public ZenModeTimePickerFragment(Context context, int hour, int minute,
@NonNull TimeSetter timeSetter) {
super();
mContext = context;
mHour = hour;
mMinute = minute;
mTimeSetter = timeSetter;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new TimePickerDialog(mContext, this, mHour, mMinute,
DateFormat.is24HourFormat(mContext));
}
/**
* Calls the provided TimeSetter's setTime() method when a time is set on the TimePicker.
*/
public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
mTimeSetter.setTime(hourOfDay, minute);
}
@Override
public int getMetricsCategory() {
// TODO: b/332937635 - set correct metrics category (or decide to keep this one?)
return SettingsEnums.DIALOG_ZEN_TIMEPICKER;
}
/**
* Interface for a method to pass into the TimePickerFragment that specifies what to do when the
* time is updated.
*/
public interface TimeSetter {
void setTime(int hour, int minute);
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (C) 2024 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.settings.notification.modes;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import android.app.AutomaticZenRule;
import android.content.Context;
import android.service.notification.ZenModeConfig;
import androidx.preference.TwoStatePreference;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import java.util.Calendar;
@RunWith(RobolectricTestRunner.class)
public class ZenModeExitAtAlarmPreferenceControllerTest {
private Context mContext;
@Mock
private ZenModesBackend mBackend;
private ZenModeExitAtAlarmPreferenceController mPrefController;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mContext = ApplicationProvider.getApplicationContext();
mPrefController = new ZenModeExitAtAlarmPreferenceController(mContext, "exit_at_alarm",
mBackend);
}
@Test
public void testUpdateState() {
TwoStatePreference preference = mock(TwoStatePreference.class);
// previously: don't exit at alarm
ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo();
scheduleInfo.days = new int[] { Calendar.MONDAY };
scheduleInfo.startHour = 1;
scheduleInfo.endHour = 2;
scheduleInfo.exitAtAlarm = false;
ZenMode mode = new ZenMode("id",
new AutomaticZenRule.Builder("name",
ZenModeConfig.toScheduleConditionId(scheduleInfo)).build(),
true); // is active
// need to call updateZenMode for the first call
mPrefController.updateZenMode(preference, mode);
verify(preference).setChecked(false);
// Now update state after changing exitAtAlarm
scheduleInfo.exitAtAlarm = true;
mode.getRule().setConditionId(ZenModeConfig.toScheduleConditionId(scheduleInfo));
// now can just call updateState
mPrefController.updateState(preference, mode);
verify(preference).setChecked(true);
}
@Test
public void testOnPreferenceChange() {
TwoStatePreference preference = mock(TwoStatePreference.class);
// previously: exit at alarm
ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo();
scheduleInfo.days = new int[] { Calendar.MONDAY };
scheduleInfo.startHour = 1;
scheduleInfo.endHour = 2;
scheduleInfo.exitAtAlarm = true;
ZenMode mode = new ZenMode("id",
new AutomaticZenRule.Builder("name",
ZenModeConfig.toScheduleConditionId(scheduleInfo)).build(),
true); // is active
mPrefController.updateZenMode(preference, mode);
// turn off exit at alarm
mPrefController.onPreferenceChange(preference, false);
ArgumentCaptor<ZenMode> captor = ArgumentCaptor.forClass(ZenMode.class);
verify(mBackend).updateMode(captor.capture());
ZenModeConfig.ScheduleInfo newSchedule = ZenModeConfig.tryParseScheduleConditionId(
captor.getValue().getRule().getConditionId());
assertThat(newSchedule.exitAtAlarm).isFalse();
// other properties remain the same
assertThat(newSchedule.startHour).isEqualTo(1);
assertThat(newSchedule.endHour).isEqualTo(2);
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 2024 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.settings.notification.modes;
import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.app.AutomaticZenRule;
import android.app.Flags;
import android.content.Context;
import android.net.Uri;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;
import android.service.notification.ZenModeConfig;
import android.view.ViewGroup;
import android.widget.ToggleButton;
import androidx.fragment.app.Fragment;
import androidx.test.core.app.ApplicationProvider;
import com.android.settings.R;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import java.util.Calendar;
@RunWith(RobolectricTestRunner.class)
public class ZenModeSetSchedulePreferenceControllerTest {
@Rule
public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(DEVICE_DEFAULT);
@Mock
private ZenModesBackend mBackend;
private Context mContext;
@Mock
private Fragment mParent;
@Mock
private Calendar mCalendar;
@Mock
private ViewGroup mDaysContainer;
@Mock
private ToggleButton mDay0, mDay1, mDay2, mDay3, mDay4, mDay5, mDay6;
private ZenModeSetSchedulePreferenceController mPrefController;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mContext = ApplicationProvider.getApplicationContext();
mPrefController = new ZenModeSetSchedulePreferenceController(mContext, mParent, "schedule",
mBackend);
setupMockDayContainer();
}
@Test
@EnableFlags({Flags.FLAG_MODES_API, Flags.FLAG_MODES_UI})
public void updateScheduleRule_updatesConditionAndTriggerDescription() {
ZenMode mode = new ZenMode("id",
new AutomaticZenRule.Builder("name", Uri.parse("condition")).build(),
true); // is active
ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo();
scheduleInfo.days = new int[] { Calendar.MONDAY };
scheduleInfo.startHour = 1;
scheduleInfo.endHour = 2;
ZenMode out = mPrefController.updateScheduleMode(scheduleInfo).apply(mode);
assertThat(out.getRule().getConditionId())
.isEqualTo(ZenModeConfig.toScheduleConditionId(scheduleInfo));
assertThat(out.getRule().getTriggerDescription()).isNotEmpty();
}
@Test
public void testUpdateScheduleDays() {
// Confirm that adding/subtracting/etc days works as expected
// starting from null: no days set
ZenModeConfig.ScheduleInfo schedule = new ZenModeConfig.ScheduleInfo();
// Unset a day that's already unset: nothing should change
assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule,
Calendar.TUESDAY, false)).isFalse();
// not explicitly checking whether schedule.days is still null here, as we don't necessarily
// want to require nullness as distinct from an empty list of days.
// set a few new days
assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule,
Calendar.MONDAY, true)).isTrue();
assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule,
Calendar.FRIDAY, true)).isTrue();
assertThat(schedule.days).hasLength(2);
assertThat(schedule.days).asList().containsExactly(Calendar.MONDAY, Calendar.FRIDAY);
// remove an existing day to make sure that works
assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule,
Calendar.MONDAY, false)).isTrue();
assertThat(schedule.days).hasLength(1);
assertThat(schedule.days).asList().containsExactly(Calendar.FRIDAY);
}
@Test
public void testSetupDayToggles_daysOfWeekOrder() {
// Confirm that days are correctly associated with the actual day of the week independent
// of when the first day of the week is for the given calendar.
ZenModeConfig.ScheduleInfo schedule = new ZenModeConfig.ScheduleInfo();
schedule.days = new int[] { Calendar.SUNDAY, Calendar.TUESDAY, Calendar.FRIDAY };
schedule.startHour = 1;
schedule.endHour = 5;
// Start mCalendar on Wednesday, arbitrarily
when(mCalendar.getFirstDayOfWeek()).thenReturn(Calendar.WEDNESDAY);
// Setup the day toggles
mPrefController.setupDayToggles(mDaysContainer, schedule, mCalendar);
// we should see toggle 0 associated with the first day of the week, etc.
// in this week order, schedule turns on friday (2), sunday (4), tuesday (6) so those
// should be checked while everything else should not be checked.
verify(mDay0).setChecked(false); // weds
verify(mDay1).setChecked(false); // thurs
verify(mDay2).setChecked(true); // fri
verify(mDay3).setChecked(false); // sat
verify(mDay4).setChecked(true); // sun
verify(mDay5).setChecked(false); // mon
verify(mDay6).setChecked(true); // tues
}
private void setupMockDayContainer() {
// associate each index (regardless of associated day of the week) with the appropriate
// res id in the days container
when(mDaysContainer.findViewById(R.id.day0)).thenReturn(mDay0);
when(mDaysContainer.findViewById(R.id.day1)).thenReturn(mDay1);
when(mDaysContainer.findViewById(R.id.day2)).thenReturn(mDay2);
when(mDaysContainer.findViewById(R.id.day3)).thenReturn(mDay3);
when(mDaysContainer.findViewById(R.id.day4)).thenReturn(mDay4);
when(mDaysContainer.findViewById(R.id.day5)).thenReturn(mDay5);
when(mDaysContainer.findViewById(R.id.day6)).thenReturn(mDay6);
}
}

View File

@@ -17,6 +17,7 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import static android.app.AutomaticZenRule.TYPE_SCHEDULE_CALENDAR; import static android.app.AutomaticZenRule.TYPE_SCHEDULE_CALENDAR;
import static android.app.AutomaticZenRule.TYPE_SCHEDULE_TIME;
import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY; import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT; import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT;
@@ -53,6 +54,8 @@ import org.mockito.Mock;
import org.mockito.MockitoAnnotations; import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import java.util.Calendar;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
public class ZenModeSetTriggerLinkPreferenceControllerTest { public class ZenModeSetTriggerLinkPreferenceControllerTest {
@Rule @Rule
@@ -167,4 +170,29 @@ public class ZenModeSetTriggerLinkPreferenceControllerTest {
captor.getValue().getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)).isEqualTo( captor.getValue().getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)).isEqualTo(
ZenModeSetCalendarFragment.class.getName()); ZenModeSetCalendarFragment.class.getName());
} }
@Test
public void testRuleLink_schedule() {
ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo();
scheduleInfo.days = new int[] { Calendar.MONDAY, Calendar.TUESDAY, Calendar.THURSDAY };
scheduleInfo.startHour = 1;
scheduleInfo.endHour = 15;
ZenMode mode = new ZenMode("id", new AutomaticZenRule.Builder("name",
ZenModeConfig.toScheduleConditionId(scheduleInfo))
.setType(TYPE_SCHEDULE_TIME)
.setTriggerDescription("some schedule")
.build(),
true); // is active
mPrefController.updateZenMode(mPrefCategory, mode);
verify(mPreference).setTitle(R.string.zen_mode_set_schedule_link);
verify(mPreference).setSummary(mode.getRule().getTriggerDescription());
ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
verify(mPreference).setIntent(captor.capture());
// Destination as written into the intent by SubSettingLauncher
assertThat(
captor.getValue().getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)).isEqualTo(
ZenModeSetScheduleFragment.class.getName());
}
} }