Update 'next alarm' tracking

Also improves logging and adds tests.

Test: runtest systemui-notification
Bug: 67028535
Bug: 69440234
Change-Id: I259fdc2d253d2a4ac415e23bd66a0b9d7c69b053
diff --git a/core/java/android/service/notification/ZenModeConfig.java b/core/java/android/service/notification/ZenModeConfig.java
index 735b822..1ec2406 100644
--- a/core/java/android/service/notification/ZenModeConfig.java
+++ b/core/java/android/service/notification/ZenModeConfig.java
@@ -890,7 +890,17 @@
     }
 
     public static boolean isValidScheduleConditionId(Uri conditionId) {
-        return tryParseScheduleConditionId(conditionId) != null;
+        ScheduleInfo info;
+        try {
+            info = tryParseScheduleConditionId(conditionId);
+        } catch (NullPointerException | ArrayIndexOutOfBoundsException e) {
+            return false;
+        }
+
+        if (info == null || info.days == null || info.days.length == 0) {
+            return false;
+        }
+        return true;
     }
 
     public static ScheduleInfo tryParseScheduleConditionId(Uri conditionId) {
diff --git a/packages/SystemUI/res/layout/volume_dialog.xml b/packages/SystemUI/res/layout/volume_dialog.xml
index fac254a..863f17b 100644
--- a/packages/SystemUI/res/layout/volume_dialog.xml
+++ b/packages/SystemUI/res/layout/volume_dialog.xml
@@ -13,7 +13,7 @@
      See the License for the specific language governing permissions and
      limitations under the License.
 -->
-<com.android.systemui.HardwareUiLayout
+<RelativeLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
@@ -45,4 +45,4 @@
         </LinearLayout>
 
     </RelativeLayout>
-</com.android.systemui.HardwareUiLayout>
+</RelativeLayout>
diff --git a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java
index 0d41e20..ca23980 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java
@@ -19,6 +19,9 @@
 import static android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK;
 import static android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_GENERIC;
 
+import static com.android.systemui.util.leak.RotationUtils.ROTATION_LANDSCAPE;
+import static com.android.systemui.util.leak.RotationUtils.ROTATION_NONE;
+import static com.android.systemui.util.leak.RotationUtils.ROTATION_SEASCAPE;
 import static com.android.systemui.volume.Events.DISMISS_REASON_TOUCH_OUTSIDE;
 
 import android.accessibilityservice.AccessibilityServiceInfo;
@@ -42,6 +45,7 @@
 import android.os.Looper;
 import android.os.Message;
 import android.os.SystemClock;
+import android.provider.Settings;
 import android.provider.Settings.Global;
 import android.util.Log;
 import android.util.Slog;
@@ -67,6 +71,7 @@
 
 import com.android.settingslib.Utils;
 import com.android.systemui.Dependency;
+import com.android.systemui.HardwareBgDrawable;
 import com.android.systemui.HardwareUiLayout;
 import com.android.systemui.Interpolators;
 import com.android.systemui.R;
@@ -74,6 +79,7 @@
 import com.android.systemui.plugins.VolumeDialogController;
 import com.android.systemui.plugins.VolumeDialogController.State;
 import com.android.systemui.plugins.VolumeDialogController.StreamState;
+import com.android.systemui.util.leak.RotationUtils;
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -97,9 +103,13 @@
     private final VolumeDialogController mController;
 
     private Window mWindow;
-    private HardwareUiLayout mHardwareLayout;
+    //private HardwareUiLayout mHardwareLayout;
     private CustomDialog mDialog;
     private ViewGroup mDialogView;
+    private boolean mEdgeBleed;
+    private boolean mRoundedDivider;
+    private HardwareBgDrawable mBackground;
+    private int mRotation = ROTATION_NONE;
     private ViewGroup mDialogRowsView;
     private ViewGroup mDialogContentView;
     private final List<VolumeRow> mRows = new ArrayList<>();
@@ -111,6 +121,8 @@
     private final Accessibility mAccessibility = new Accessibility();
     private final ColorStateList mActiveSliderTint;
     private final ColorStateList mInactiveSliderTint;
+    private static final String EDGE_BLEED = "sysui_hwui_edge_bleed";
+    private static final String ROUNDED_DIVIDER = "sysui_hwui_rounded_divider";
 
     private boolean mShowing;
     private boolean mShowA11yStream;
@@ -181,8 +193,16 @@
                 return true;
             }
         });
-        mHardwareLayout = HardwareUiLayout.get(mDialogView);
-        mHardwareLayout.setOutsideTouchListener(view -> dismiss(DISMISS_REASON_TOUCH_OUTSIDE));
+
+        mEdgeBleed = Settings.Secure.getInt(mContext.getContentResolver(),
+                EDGE_BLEED, 0) != 0;
+        mRoundedDivider = Settings.Secure.getInt(mContext.getContentResolver(),
+                ROUNDED_DIVIDER, 1) != 0;
+        updateEdgeMargin(mEdgeBleed ? 0 : getEdgePadding());
+        mBackground = new HardwareBgDrawable(mRoundedDivider, !mEdgeBleed, mContext);
+        mDialogView.setBackground(mBackground);
+        //mHardwareLayout = HardwareUiLayout.get(mDialogView);
+        //mHardwareLayout.setOutsideTouchListener(view -> dismiss(DISMISS_REASON_TOUCH_OUTSIDE));
 
         mDialogContentView = mDialog.findViewById(R.id.volume_dialog_content);
         mDialogRowsView = mDialogContentView.findViewById(R.id.volume_dialog_rows);
@@ -210,6 +230,25 @@
         updateRowsH(getActiveRow());
     }
 
+    private int getEdgePadding() {
+        return mContext.getResources().getDimensionPixelSize(R.dimen.edge_margin);
+    }
+
+    private void updateEdgeMargin(int edge) {
+        if (mDialogView != null) {
+            mRotation = RotationUtils.getRotation(mContext);
+            ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) mDialogView.getLayoutParams();
+            if (mRotation == ROTATION_LANDSCAPE) {
+                params.topMargin = edge;
+            } else if (mRotation == ROTATION_SEASCAPE) {
+                params.bottomMargin = edge;
+            } else {
+                params.rightMargin = edge;
+            }
+            mDialogView.setLayoutParams(params);
+        }
+    }
+
     private ColorStateList loadColorStateList(int colorResId) {
         return ColorStateList.valueOf(mContext.getColor(colorResId));
     }
@@ -389,11 +428,11 @@
         rescheduleTimeoutH();
         if (mShowing) return;
         mShowing = true;
-        mHardwareLayout.setTranslationX(getAnimTranslation());
-        mHardwareLayout.setAlpha(0);
-        mHardwareLayout.animate()
+        mDialogView.setTranslationY(getAnimTranslation());
+        mDialogView.setAlpha(0);
+        mDialogView.animate()
                 .alpha(1)
-                .translationX(0)
+                .translationY(0)
                 .setDuration(300)
                 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
                 .withEndAction(() -> {
@@ -432,9 +471,9 @@
         mHandler.removeMessages(H.SHOW);
         if (!mShowing) return;
         mShowing = false;
-        mHardwareLayout.setTranslationX(0);
-        mHardwareLayout.setAlpha(1);
-        mHardwareLayout.animate()
+        mDialogView.setTranslationX(0);
+        mDialogView.setAlpha(1);
+        mDialogView.animate()
                 .alpha(0)
                 .translationX(getAnimTranslation())
                 .setDuration(300)
diff --git a/services/core/java/com/android/server/notification/ScheduleCalendar.java b/services/core/java/com/android/server/notification/ScheduleCalendar.java
index 40230bd..5ff0e21 100644
--- a/services/core/java/com/android/server/notification/ScheduleCalendar.java
+++ b/services/core/java/com/android/server/notification/ScheduleCalendar.java
@@ -18,6 +18,7 @@
 
 import android.service.notification.ZenModeConfig.ScheduleInfo;
 import android.util.ArraySet;
+import android.util.Log;
 
 import java.util.Calendar;
 import java.util.Objects;
@@ -41,10 +42,25 @@
     }
 
     public void maybeSetNextAlarm(long now, long nextAlarm) {
-        if (mSchedule != null) {
-            if (mSchedule.exitAtAlarm
-                    && (now > mSchedule.nextAlarm || nextAlarm < mSchedule.nextAlarm)) {
-                mSchedule.nextAlarm = nextAlarm;
+        if (mSchedule != null && mSchedule.exitAtAlarm) {
+            // alarm canceled
+            if (nextAlarm == 0) {
+                mSchedule.nextAlarm = 0;
+            }
+            // only allow alarms in the future
+            if (nextAlarm > now) {
+                // store earliest alarm
+                if (mSchedule.nextAlarm == 0) {
+                    mSchedule.nextAlarm = nextAlarm;
+                } else {
+                    mSchedule.nextAlarm = Math.min(mSchedule.nextAlarm, nextAlarm);
+                }
+            } else if (mSchedule.nextAlarm < now) {
+                if (ScheduleConditionProvider.DEBUG) {
+                    Log.d(ScheduleConditionProvider.TAG,
+                            "All alarms are in the past " + mSchedule.nextAlarm);
+                }
+                mSchedule.nextAlarm = 0;
             }
         }
     }
@@ -87,6 +103,9 @@
     }
 
     public boolean shouldExitForAlarm(long time) {
+        if (mSchedule == null) {
+            return false;
+        }
         return mSchedule.exitAtAlarm
                 && mSchedule.nextAlarm != 0
                 && time >= mSchedule.nextAlarm;
diff --git a/services/core/java/com/android/server/notification/ScheduleConditionProvider.java b/services/core/java/com/android/server/notification/ScheduleConditionProvider.java
index 50a51b2..c5f80bb 100644
--- a/services/core/java/com/android/server/notification/ScheduleConditionProvider.java
+++ b/services/core/java/com/android/server/notification/ScheduleConditionProvider.java
@@ -37,6 +37,8 @@
 import android.util.Log;
 import android.util.Slog;
 
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.notification.NotificationManagerService.DumpFilter;
 
 import java.io.PrintWriter;
@@ -62,10 +64,9 @@
     private static final String SEPARATOR = ";";
     private static final String SCP_SETTING = "snoozed_schedule_condition_provider";
 
-
     private final Context mContext = this;
     private final ArrayMap<Uri, ScheduleCalendar> mSubscriptions = new ArrayMap<>();
-    private ArraySet<Uri> mSnoozed = new ArraySet<>();
+    private ArraySet<Uri> mSnoozedForAlarm = new ArraySet<>();
 
     private AlarmManager mAlarmManager;
     private boolean mConnected;
@@ -102,7 +103,7 @@
                 pw.println(mSubscriptions.get(conditionId).toString());
             }
         }
-        pw.println("      snoozed due to alarm: " + TextUtils.join(SEPARATOR, mSnoozed));
+        pw.println("      snoozed due to alarm: " + TextUtils.join(SEPARATOR, mSnoozedForAlarm));
         dumpUpcomingTime(pw, "mNextAlarmTime", mNextAlarmTime, now);
     }
 
@@ -129,7 +130,7 @@
     public void onSubscribe(Uri conditionId) {
         if (DEBUG) Slog.d(TAG, "onSubscribe " + conditionId);
         if (!ZenModeConfig.isValidScheduleConditionId(conditionId)) {
-            notifyCondition(createCondition(conditionId, Condition.STATE_FALSE, "badCondition"));
+            notifyCondition(createCondition(conditionId, Condition.STATE_ERROR, "invalidId"));
             return;
         }
         synchronized (mSubscriptions) {
@@ -169,32 +170,11 @@
         synchronized (mSubscriptions) {
             setRegistered(!mSubscriptions.isEmpty());
             for (Uri conditionId : mSubscriptions.keySet()) {
-                final ScheduleCalendar cal = mSubscriptions.get(conditionId);
-                if (cal != null && cal.isInSchedule(now)) {
-                    if (conditionSnoozed(conditionId) || cal.shouldExitForAlarm(now)) {
-                        conditionsToNotify.add(createCondition(
-                                conditionId, Condition.STATE_FALSE, "alarmCanceled"));
-                        addSnoozed(conditionId);
-                    } else {
-                        conditionsToNotify.add(createCondition(
-                                conditionId, Condition.STATE_TRUE, "meetsSchedule"));
-                    }
-                    cal.maybeSetNextAlarm(now, nextUserAlarmTime);
-                } else {
-                    conditionsToNotify.add(createCondition(
-                            conditionId, Condition.STATE_FALSE, "!meetsSchedule"));
-                    removeSnoozed(conditionId);
-                    if (cal != null && nextUserAlarmTime == 0) {
-                        cal.maybeSetNextAlarm(now, nextUserAlarmTime);
-                    }
-                }
-                if (cal != null) {
-                    final long nextChangeTime = cal.getNextChangeTime(now);
-                    if (nextChangeTime > 0 && nextChangeTime > now) {
-                        if (mNextAlarmTime == 0 || nextChangeTime < mNextAlarmTime) {
-                            mNextAlarmTime = nextChangeTime;
-                        }
-                    }
+                Condition condition =
+                        evaluateSubscriptionLocked(conditionId, mSubscriptions.get(conditionId),
+                                now, nextUserAlarmTime);
+                if (condition != null) {
+                    conditionsToNotify.add(condition);
                 }
             }
         }
@@ -202,6 +182,39 @@
         updateAlarm(now, mNextAlarmTime);
     }
 
+    @VisibleForTesting
+    @GuardedBy("mSubscriptions")
+    Condition evaluateSubscriptionLocked(Uri conditionId, ScheduleCalendar cal,
+            long now, long nextUserAlarmTime) {
+        Condition condition;
+        if (cal == null) {
+            condition = createCondition(conditionId, Condition.STATE_ERROR, "!invalidId");
+            removeSnoozed(conditionId);
+            return condition;
+        }
+        if (cal.isInSchedule(now)) {
+            if (conditionSnoozed(conditionId)) {
+                condition = createCondition(conditionId, Condition.STATE_FALSE, "snoozed");
+            } else if (cal.shouldExitForAlarm(now)) {
+                condition = createCondition(conditionId, Condition.STATE_FALSE, "alarmCanceled");
+                addSnoozed(conditionId);
+            } else {
+                condition = createCondition(conditionId, Condition.STATE_TRUE, "meetsSchedule");
+            }
+        } else {
+            condition = createCondition(conditionId, Condition.STATE_FALSE, "!meetsSchedule");
+            removeSnoozed(conditionId);
+        }
+        cal.maybeSetNextAlarm(now, nextUserAlarmTime);
+        final long nextChangeTime = cal.getNextChangeTime(now);
+        if (nextChangeTime > 0 && nextChangeTime > now) {
+            if (mNextAlarmTime == 0 || nextChangeTime < mNextAlarmTime) {
+                mNextAlarmTime = nextChangeTime;
+            }
+        }
+        return condition;
+    }
+
     private void updateAlarm(long now, long time) {
         final AlarmManager alarms = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
         final PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext,
@@ -266,27 +279,28 @@
     }
 
     private boolean conditionSnoozed(Uri conditionId) {
-        synchronized (mSnoozed) {
-            return mSnoozed.contains(conditionId);
+        synchronized (mSnoozedForAlarm) {
+            return mSnoozedForAlarm.contains(conditionId);
         }
     }
 
-    private void addSnoozed(Uri conditionId) {
-        synchronized (mSnoozed) {
-            mSnoozed.add(conditionId);
+    @VisibleForTesting
+    void addSnoozed(Uri conditionId) {
+        synchronized (mSnoozedForAlarm) {
+            mSnoozedForAlarm.add(conditionId);
             saveSnoozedLocked();
         }
     }
 
     private void removeSnoozed(Uri conditionId) {
-        synchronized (mSnoozed) {
-            mSnoozed.remove(conditionId);
+        synchronized (mSnoozedForAlarm) {
+            mSnoozedForAlarm.remove(conditionId);
             saveSnoozedLocked();
         }
     }
 
-    public void saveSnoozedLocked() {
-        final String setting = TextUtils.join(SEPARATOR, mSnoozed);
+    private void saveSnoozedLocked() {
+        final String setting = TextUtils.join(SEPARATOR, mSnoozedForAlarm);
         final int currentUser = ActivityManager.getCurrentUser();
         Settings.Secure.putStringForUser(mContext.getContentResolver(),
                 SCP_SETTING,
@@ -294,8 +308,8 @@
                 currentUser);
     }
 
-    public void readSnoozed() {
-        synchronized (mSnoozed) {
+    private void readSnoozed() {
+        synchronized (mSnoozedForAlarm) {
             long identity = Binder.clearCallingIdentity();
             try {
                 final String setting = Settings.Secure.getStringForUser(
@@ -312,7 +326,7 @@
                         if (TextUtils.isEmpty(token)) {
                             continue;
                         }
-                        mSnoozed.add(Uri.parse(token));
+                        mSnoozedForAlarm.add(Uri.parse(token));
                     }
                 }
             } finally {
diff --git a/services/tests/notification/src/com/android/server/notification/ScheduleCalendarTest.java b/services/tests/notification/src/com/android/server/notification/ScheduleCalendarTest.java
new file mode 100644
index 0000000..cbda12d
--- /dev/null
+++ b/services/tests/notification/src/com/android/server/notification/ScheduleCalendarTest.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2017 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.server.notification;
+
+import static junit.framework.Assert.assertFalse;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.service.notification.ZenModeConfig;
+import android.support.test.runner.AndroidJUnit4;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.util.Slog;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ScheduleCalendarTest extends NotificationTestCase {
+
+    private ScheduleCalendar mScheduleCalendar;
+    private ZenModeConfig.ScheduleInfo mScheduleInfo;
+
+    @Before
+    public void setUp() throws Exception {
+        mScheduleCalendar = new ScheduleCalendar();
+        mScheduleInfo = new ZenModeConfig.ScheduleInfo();
+        mScheduleInfo.days = new int[] {1, 2, 3, 4, 5};
+        mScheduleCalendar.setSchedule(mScheduleInfo);
+    }
+
+    @Test
+    public void testNullScheduleInfo() throws Exception {
+        mScheduleCalendar.setSchedule(null);
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 1999);
+        assertEquals(0, mScheduleCalendar.getNextChangeTime(1000));
+        assertFalse(mScheduleCalendar.isInSchedule(100));
+        assertFalse(mScheduleCalendar.shouldExitForAlarm(100));
+    }
+
+    @Test
+    public void testGetNextChangeTime_startToday() throws Exception {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 1);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay()};
+        mScheduleInfo.startHour = cal.get(Calendar.HOUR_OF_DAY) + 1;
+        mScheduleInfo.endHour = cal.get(Calendar.HOUR_OF_DAY) + 3;
+        mScheduleInfo.startMinute = 15;
+        mScheduleInfo.endMinute = 15;
+        mScheduleInfo.exitAtAlarm = false;
+        mScheduleCalendar.setSchedule(mScheduleInfo);
+
+        Calendar expected = new GregorianCalendar();
+        expected.setTimeInMillis(cal.getTimeInMillis());
+        expected.set(Calendar.HOUR_OF_DAY, mScheduleInfo.startHour);
+
+        long actualMs = mScheduleCalendar.getNextChangeTime(cal.getTimeInMillis());
+        GregorianCalendar actual = new GregorianCalendar();
+        actual.setTimeInMillis(actualMs);
+        assertEquals("Expected " + expected + " was " + actual, expected.getTimeInMillis(),
+                actualMs);
+    }
+
+    @Test
+    public void testGetNextChangeTime_endToday() throws Exception {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 2);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay()};
+        mScheduleInfo.startHour = cal.get(Calendar.HOUR_OF_DAY) - 1;
+        mScheduleInfo.endHour = cal.get(Calendar.HOUR_OF_DAY) + 3;
+        mScheduleInfo.startMinute = 15;
+        mScheduleInfo.endMinute = 15;
+        mScheduleInfo.exitAtAlarm = false;
+
+        Calendar expected = new GregorianCalendar();
+        expected.setTimeInMillis(cal.getTimeInMillis());
+        expected.set(Calendar.HOUR_OF_DAY, mScheduleInfo.endHour);
+        expected.set(Calendar.MINUTE, mScheduleInfo.endMinute);
+
+        long actualMs = mScheduleCalendar.getNextChangeTime(cal.getTimeInMillis());
+        GregorianCalendar actual = new GregorianCalendar();
+        actual.setTimeInMillis(actualMs);
+        assertEquals("Expected " + expected + " was " + actual, expected.getTimeInMillis(),
+                actualMs);
+    }
+
+    @Test
+    public void testGetNextChangeTime_startTomorrow() throws Exception {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay(), getTodayDay() + 1};
+        mScheduleInfo.startHour = 1;
+        mScheduleInfo.endHour = 3;
+        mScheduleInfo.startMinute = 15;
+        mScheduleInfo.endMinute = 15;
+        mScheduleInfo.exitAtAlarm = false;
+
+        Calendar expected = new GregorianCalendar();
+        expected.setTimeInMillis(cal.getTimeInMillis());
+        expected.add(Calendar.DATE, 1);
+        expected.set(Calendar.HOUR_OF_DAY, mScheduleInfo.startHour);
+        expected.set(Calendar.MINUTE, mScheduleInfo.startMinute);
+
+        long actualMs = mScheduleCalendar.getNextChangeTime(cal.getTimeInMillis());
+        GregorianCalendar actual = new GregorianCalendar();
+        actual.setTimeInMillis(actualMs);
+        assertEquals("Expected " + expected + " was " + actual, expected.getTimeInMillis(),
+                actualMs);
+    }
+
+    @Test
+    public void testGetNextChangeTime_endTomorrow() throws Exception {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay(), getTodayDay() + 1};
+        mScheduleInfo.startHour = 22;
+        mScheduleInfo.endHour = 3;
+        mScheduleInfo.startMinute = 15;
+        mScheduleInfo.endMinute = 15;
+        mScheduleInfo.exitAtAlarm = false;
+
+        Calendar expected = new GregorianCalendar();
+        expected.setTimeInMillis(cal.getTimeInMillis());
+        expected.add(Calendar.DATE, 1);
+        expected.set(Calendar.HOUR_OF_DAY, mScheduleInfo.endHour);
+        expected.set(Calendar.MINUTE, mScheduleInfo.endMinute);
+
+        long actualMs = mScheduleCalendar.getNextChangeTime(cal.getTimeInMillis());
+        GregorianCalendar actual = new GregorianCalendar();
+        actual.setTimeInMillis(actualMs);
+        assertEquals("Expected " + expected + " was " + actual, expected.getTimeInMillis(),
+                actualMs);
+    }
+
+    @Test
+    public void testShouldExitForAlarm_settingOff() {
+        mScheduleInfo.exitAtAlarm = false;
+        mScheduleInfo.nextAlarm = 1000;
+
+        assertFalse(mScheduleCalendar.shouldExitForAlarm(1000));
+    }
+
+    @Test
+    public void testShouldExitForAlarm_beforeAlarm() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 1000;
+
+        assertFalse(mScheduleCalendar.shouldExitForAlarm(999));
+    }
+
+    @Test
+    public void testShouldExitForAlarm_noAlarm() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 0;
+
+        assertFalse(mScheduleCalendar.shouldExitForAlarm(999));
+    }
+
+    @Test
+    public void testShouldExitForAlarm() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 1000;
+
+        assertTrue(mScheduleCalendar.shouldExitForAlarm(1000));
+    }
+
+    @Test
+    public void testMaybeSetNextAlarm_settingOff() {
+        mScheduleInfo.exitAtAlarm = false;
+        mScheduleInfo.nextAlarm = 0;
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 2000);
+
+        assertEquals(0, mScheduleInfo.nextAlarm);
+    }
+
+    @Test
+    public void testMaybeSetNextAlarm_settingOn() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 0;
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 2000);
+
+        assertEquals(2000, mScheduleInfo.nextAlarm);
+    }
+
+    @Test
+    public void testMaybeSetNextAlarm_alarmCanceled() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 10000;
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 0);
+
+        assertEquals(0, mScheduleInfo.nextAlarm);
+    }
+
+    @Test
+    public void testMaybeSetNextAlarm_earlierAlarm() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 2000;
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 1500);
+
+        assertEquals(1500, mScheduleInfo.nextAlarm);
+    }
+
+    @Test
+    public void testMaybeSetNextAlarm_laterAlarm() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 2000;
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 3000);
+
+        assertEquals(2000, mScheduleInfo.nextAlarm);
+    }
+
+    @Test
+    public void testMaybeSetNextAlarm_expiredAlarm() {
+        mScheduleInfo.exitAtAlarm = true;
+        mScheduleInfo.nextAlarm = 998;
+
+        mScheduleCalendar.maybeSetNextAlarm(1000, 999);
+
+        assertEquals(0, mScheduleInfo.nextAlarm);
+    }
+
+    @Test
+    public void testIsInSchedule_inScheduleOvernight() {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay()};
+        mScheduleInfo.startHour = 22;
+        mScheduleInfo.endHour = 3;
+        mScheduleInfo.startMinute = 15;
+        mScheduleInfo.endMinute = 15;
+
+        assertTrue(mScheduleCalendar.isInSchedule(cal.getTimeInMillis()));
+    }
+
+    @Test
+    public void testIsInSchedule_inScheduleSingleDay() {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 14);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay()};
+        mScheduleInfo.startHour = 12;
+        mScheduleInfo.endHour = 3;
+        mScheduleInfo.startMinute = 16;
+        mScheduleInfo.endMinute = 15;
+
+        assertTrue(mScheduleCalendar.isInSchedule(cal.getTimeInMillis()));
+    }
+
+    @Test
+    public void testIsInSchedule_notToday() {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 14);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        cal.set(Calendar.DAY_OF_WEEK, Calendar.SATURDAY);
+        mScheduleInfo.days = new int[] {Calendar.FRIDAY, Calendar.SUNDAY};
+        mScheduleInfo.startHour = 12;
+        mScheduleInfo.startMinute = 16;
+        mScheduleInfo.endHour = 15;
+        mScheduleInfo.endMinute = 15;
+
+        assertFalse(mScheduleCalendar.isInSchedule(cal.getTimeInMillis()));
+    }
+
+    @Test
+    public void testIsInSchedule_startingSoon() {
+        Calendar cal = new GregorianCalendar();
+        cal.set(Calendar.HOUR_OF_DAY, 14);
+        cal.set(Calendar.MINUTE, 15);
+        cal.set(Calendar.SECOND, 59);
+        cal.set(Calendar.MILLISECOND, 0);
+        mScheduleInfo.days = new int[] {getTodayDay()};
+        mScheduleInfo.startHour = 14;
+        mScheduleInfo.endHour = 3;
+        mScheduleInfo.startMinute = 16;
+        mScheduleInfo.endMinute = 15;
+
+        assertFalse(mScheduleCalendar.isInSchedule(cal.getTimeInMillis()));
+    }
+
+    private int getTodayDay() {
+        return new GregorianCalendar().get(Calendar.DAY_OF_WEEK);
+    }
+}
diff --git a/services/tests/notification/src/com/android/server/notification/ScheduleConditionProviderTest.java b/services/tests/notification/src/com/android/server/notification/ScheduleConditionProviderTest.java
new file mode 100644
index 0000000..ddf46a0
--- /dev/null
+++ b/services/tests/notification/src/com/android/server/notification/ScheduleConditionProviderTest.java
@@ -0,0 +1,337 @@
+package com.android.server.notification;
+
+import static org.mockito.Mockito.spy;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Looper;
+import android.service.notification.Condition;
+import android.service.notification.ZenModeConfig;
+import android.support.test.InstrumentationRegistry;
+import android.test.ServiceTestCase;
+import android.testing.TestableContext;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+public class ScheduleConditionProviderTest extends ServiceTestCase<ScheduleConditionProvider> {
+
+    ScheduleConditionProvider mService;
+
+    @Rule
+    public final TestableContext mContext =
+            new TestableContext(InstrumentationRegistry.getContext(), null);
+
+    public ScheduleConditionProviderTest() {
+        super(ScheduleConditionProvider.class);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        Looper.prepare();
+
+        Intent startIntent =
+                new Intent("com.android.server.notification.ScheduleConditionProvider");
+        startIntent.setPackage("android");
+        bindService(startIntent);
+        mService = spy(getService());
+   }
+
+    @Test
+    public void testIsValidConditionId_incomplete() throws Exception {
+        Uri badConditionId = Uri.EMPTY;
+        assertFalse(mService.isValidConditionId(badConditionId));
+        assertEquals(Condition.STATE_ERROR,
+                mService.evaluateSubscriptionLocked(badConditionId, null, 0, 1000).state);
+    }
+
+    @Test
+    public void testIsValidConditionId() throws Exception {
+        ZenModeConfig.ScheduleInfo info = new ZenModeConfig.ScheduleInfo();
+        info.days = new int[] {1, 2, 4};
+        info.startHour = 8;
+        info.startMinute = 56;
+        info.nextAlarm = 1000;
+        info.exitAtAlarm = true;
+        info.endHour = 12;
+        info.endMinute = 9;
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        assertTrue(mService.isValidConditionId(conditionId));
+    }
+
+    @Test
+    public void testEvaluateSubscription_noAlarmExit_InSchedule() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now
+        ZenModeConfig.ScheduleInfo info = new ZenModeConfig.ScheduleInfo();
+        info.days = new int[] {Calendar.FRIDAY};
+        info.startHour = now.get(Calendar.HOUR_OF_DAY);
+        info.startMinute = now.get(Calendar.MINUTE);
+        info.nextAlarm = 0;
+        info.exitAtAlarm = false;
+        info.endHour = now.get(Calendar.HOUR_OF_DAY) + 1;
+        info.endMinute = info.startMinute;
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+        assertTrue(cal.isInSchedule(now.getTimeInMillis()));
+
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 1000);
+
+        assertEquals(Condition.STATE_TRUE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_noAlarmExit_InScheduleSnoozed() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now
+        ZenModeConfig.ScheduleInfo info = new ZenModeConfig.ScheduleInfo();
+        info.days = new int[] {Calendar.FRIDAY};
+        info.startHour = now.get(Calendar.HOUR_OF_DAY);
+        info.startMinute = now.get(Calendar.MINUTE);
+        info.nextAlarm = 0;
+        info.exitAtAlarm = false;
+        info.endHour = now.get(Calendar.HOUR_OF_DAY) + 1;
+        info.endMinute = info.startMinute;
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+        assertTrue(cal.isInSchedule(now.getTimeInMillis()));
+
+        mService.addSnoozed(conditionId);
+
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 1000);
+
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_noAlarmExit_beforeSchedule() {
+        Calendar now = new GregorianCalendar();
+        now.set(Calendar.HOUR_OF_DAY, 14);
+        now.set(Calendar.MINUTE, 15);
+        now.set(Calendar.SECOND, 59);
+        now.set(Calendar.MILLISECOND, 0);
+        now.set(Calendar.DAY_OF_WEEK, Calendar.FRIDAY);
+
+        // Schedule - 1 hour long; starts in 1 second
+        ZenModeConfig.ScheduleInfo info = new ZenModeConfig.ScheduleInfo();
+        info.days = new int[] {Calendar.FRIDAY};
+        info.startHour = now.get(Calendar.HOUR_OF_DAY);
+        info.startMinute = now.get(Calendar.MINUTE) + 1;
+        info.nextAlarm = 0;
+        info.exitAtAlarm = false;
+        info.endHour = now.get(Calendar.HOUR_OF_DAY) + 1;
+        info.endMinute = info.startMinute;
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 1000);
+
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_noAlarmExit_endSchedule() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; ends now
+        ZenModeConfig.ScheduleInfo info = new ZenModeConfig.ScheduleInfo();
+        info.days = new int[] {Calendar.FRIDAY};
+        info.startHour = now.get(Calendar.HOUR_OF_DAY) - 1;
+        info.startMinute = now.get(Calendar.MINUTE);
+        info.nextAlarm = 0;
+        info.exitAtAlarm = false;
+        info.endHour = now.get(Calendar.HOUR_OF_DAY);
+        info.endMinute = now.get(Calendar.MINUTE);
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 1000);
+
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_alarmSetBeforeInSchedule() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now, ends with alarm
+        ZenModeConfig.ScheduleInfo info = getScheduleEndsInHour(now);
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        // an hour before start, update with an alarm that will fire during the schedule
+        mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() - 1000, now.getTimeInMillis() + 1000);
+
+        // at start, should be in dnd
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 1000);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // at alarm fire time, should exit dnd
+        assertTrue(cal.isInSchedule(now.getTimeInMillis() + 1000));
+        assertTrue("" + info.nextAlarm + " " + now.getTimeInMillis(),
+                cal.shouldExitForAlarm(now.getTimeInMillis() + 1000));
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 1000, 0);
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_alarmSetInSchedule() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now, ends with alarm
+        ZenModeConfig.ScheduleInfo info = getScheduleEndsInHour(now);
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        // at start, should be in dnd
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), 0);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // in schedule, update with alarm time, should be in dnd
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 500, now.getTimeInMillis() + 1000);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // at alarm fire time, should exit dnd
+        assertTrue(cal.isInSchedule(now.getTimeInMillis() + 1000));
+        assertTrue("" + info.nextAlarm + " " + now.getTimeInMillis(),
+                cal.shouldExitForAlarm(now.getTimeInMillis() + 1000));
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 1000, 0);
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_earlierAlarmSet() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now, ends with alarm
+        ZenModeConfig.ScheduleInfo info = getScheduleEndsInHour(now);
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        // at start, should be in dnd, alarm in 2000 ms
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 2000);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // in schedule, update with earlier alarm time, should be in dnd
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 500, now.getTimeInMillis() + 1000);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // at earliest alarm fire time, should exit dnd
+        assertTrue(cal.isInSchedule(now.getTimeInMillis() + 1000));
+        assertTrue("" + info.nextAlarm + " " + now.getTimeInMillis(),
+                cal.shouldExitForAlarm(now.getTimeInMillis() + 1000));
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 1000, 0);
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_laterAlarmSet() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now, ends with alarm
+        ZenModeConfig.ScheduleInfo info = getScheduleEndsInHour(now);
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        // at start, should be in dnd, alarm in 500 ms
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 500);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // in schedule, update with later alarm time, should be in dnd
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 250, now.getTimeInMillis() + 1000);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // at earliest alarm fire time, should exit dnd
+        assertTrue(cal.isInSchedule(now.getTimeInMillis() + 500));
+        assertTrue("" + info.nextAlarm + " " + now.getTimeInMillis(),
+                cal.shouldExitForAlarm(now.getTimeInMillis() + 500));
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 500, 0);
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    @Test
+    public void testEvaluateSubscription_alarmCanceled() {
+        Calendar now = getNow();
+
+        // Schedule - 1 hour long; starts now, ends with alarm
+        ZenModeConfig.ScheduleInfo info = getScheduleEndsInHour(now);
+        Uri conditionId = ZenModeConfig.toScheduleConditionId(info);
+        ScheduleCalendar cal = new ScheduleCalendar();
+        cal.setSchedule(info);
+
+        // at start, should be in dnd, alarm in 500 ms
+        Condition condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis(), now.getTimeInMillis() + 500);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // in schedule, cancel alarm
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 250, 0);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // at previous alarm time, should not exit DND
+        assertTrue(cal.isInSchedule(now.getTimeInMillis() + 500));
+        assertFalse(cal.shouldExitForAlarm(now.getTimeInMillis() + 500));
+        condition = mService.evaluateSubscriptionLocked(
+                conditionId, cal, now.getTimeInMillis() + 500, 0);
+        assertEquals(Condition.STATE_TRUE, condition.state);
+
+        // end of schedule, exit DND
+        now.add(Calendar.HOUR_OF_DAY, 1);
+        condition = mService.evaluateSubscriptionLocked(conditionId, cal, now.getTimeInMillis(), 0);
+        assertEquals(Condition.STATE_FALSE, condition.state);
+    }
+
+    private Calendar getNow() {
+        Calendar now = new GregorianCalendar();
+        now.set(Calendar.HOUR_OF_DAY, 14);
+        now.set(Calendar.MINUTE, 16);
+        now.set(Calendar.SECOND, 0);
+        now.set(Calendar.MILLISECOND, 0);
+        now.set(Calendar.DAY_OF_WEEK, Calendar.FRIDAY);
+        return now;
+    }
+
+    private ZenModeConfig.ScheduleInfo getScheduleEndsInHour(Calendar now) {
+        ZenModeConfig.ScheduleInfo info = new ZenModeConfig.ScheduleInfo();
+        info.days = new int[] {Calendar.FRIDAY};
+        info.startHour = now.get(Calendar.HOUR_OF_DAY);
+        info.startMinute = now.get(Calendar.MINUTE);
+        info.exitAtAlarm = true;
+        info.endHour = now.get(Calendar.HOUR_OF_DAY) + 1;
+        info.endMinute = now.get(Calendar.MINUTE);
+        return info;
+    }
+}