1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.deskclock.data; 18 19 import android.app.Notification; 20 import android.app.PendingIntent; 21 import android.content.BroadcastReceiver; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.IntentFilter; 25 import android.content.res.Resources; 26 import android.os.SystemClock; 27 import android.support.annotation.IdRes; 28 import android.support.annotation.StringRes; 29 import android.support.v4.app.NotificationCompat; 30 import android.support.v4.app.NotificationManagerCompat; 31 import android.widget.RemoteViews; 32 33 import com.android.deskclock.HandleDeskClockApiCalls; 34 import com.android.deskclock.R; 35 import com.android.deskclock.stopwatch.StopwatchService; 36 37 import java.util.Collections; 38 import java.util.List; 39 40 import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; 41 import static android.view.View.GONE; 42 import static android.view.View.INVISIBLE; 43 import static android.view.View.VISIBLE; 44 45 /** 46 * All {@link Stopwatch} data is accessed via this model. 47 */ 48 final class StopwatchModel { 49 50 private final Context mContext; 51 52 /** The model from which notification data are fetched. */ 53 private final NotificationModel mNotificationModel; 54 55 /** Used to create and destroy system notifications related to the stopwatch. */ 56 private final NotificationManagerCompat mNotificationManager; 57 58 /** Update stopwatch notification when locale changes. */ 59 private final BroadcastReceiver mLocaleChangedReceiver = new LocaleChangedReceiver(); 60 61 /** The current state of the stopwatch. */ 62 private Stopwatch mStopwatch; 63 64 /** A mutable copy of the recorded stopwatch laps. */ 65 private List<Lap> mLaps; 66 StopwatchModel(Context context, NotificationModel notificationModel)67 StopwatchModel(Context context, NotificationModel notificationModel) { 68 mContext = context; 69 mNotificationModel = notificationModel; 70 mNotificationManager = NotificationManagerCompat.from(context); 71 72 // Update stopwatch notification when locale changes. 73 final IntentFilter localeBroadcastFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); 74 mContext.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter); 75 } 76 77 /** 78 * @return the current state of the stopwatch 79 */ getStopwatch()80 Stopwatch getStopwatch() { 81 if (mStopwatch == null) { 82 mStopwatch = StopwatchDAO.getStopwatch(mContext); 83 } 84 85 return mStopwatch; 86 } 87 88 /** 89 * @param stopwatch the new state of the stopwatch 90 */ setStopwatch(Stopwatch stopwatch)91 Stopwatch setStopwatch(Stopwatch stopwatch) { 92 if (mStopwatch != stopwatch) { 93 StopwatchDAO.setStopwatch(mContext, stopwatch); 94 mStopwatch = stopwatch; 95 96 // Refresh the stopwatch notification to reflect the latest stopwatch state. 97 if (!mNotificationModel.isApplicationInForeground()) { 98 updateNotification(); 99 } 100 } 101 102 return stopwatch; 103 } 104 105 /** 106 * @return the laps recorded for this stopwatch 107 */ getLaps()108 List<Lap> getLaps() { 109 return Collections.unmodifiableList(getMutableLaps()); 110 } 111 112 /** 113 * @return a newly recorded lap completed now; {@code null} if no more laps can be added 114 */ addLap()115 Lap addLap() { 116 if (!canAddMoreLaps()) { 117 return null; 118 } 119 120 final long totalTime = getStopwatch().getTotalTime(); 121 final List<Lap> laps = getMutableLaps(); 122 123 final int lapNumber = laps.size() + 1; 124 StopwatchDAO.addLap(mContext, lapNumber, totalTime); 125 126 final long prevAccumulatedTime = laps.isEmpty() ? 0 : laps.get(0).getAccumulatedTime(); 127 final long lapTime = totalTime - prevAccumulatedTime; 128 129 final Lap lap = new Lap(lapNumber, lapTime, totalTime); 130 laps.add(0, lap); 131 132 // Refresh the stopwatch notification to reflect the latest stopwatch state. 133 if (!mNotificationModel.isApplicationInForeground()) { 134 updateNotification(); 135 } 136 137 return lap; 138 } 139 140 /** 141 * Clears the laps recorded for this stopwatch. 142 */ clearLaps()143 void clearLaps() { 144 StopwatchDAO.clearLaps(mContext); 145 getMutableLaps().clear(); 146 } 147 148 /** 149 * @return {@code true} iff more laps can be recorded 150 */ canAddMoreLaps()151 boolean canAddMoreLaps() { 152 return getLaps().size() < 98; 153 } 154 155 /** 156 * @return the longest lap time of all recorded laps and the current lap 157 */ getLongestLapTime()158 long getLongestLapTime() { 159 long maxLapTime = 0; 160 161 final List<Lap> laps = getLaps(); 162 if (!laps.isEmpty()) { 163 // Compute the maximum lap time across all recorded laps. 164 for (Lap lap : getLaps()) { 165 maxLapTime = Math.max(maxLapTime, lap.getLapTime()); 166 } 167 168 // Compare with the maximum lap time for the current lap. 169 final Stopwatch stopwatch = getStopwatch(); 170 final long currentLapTime = stopwatch.getTotalTime() - laps.get(0).getAccumulatedTime(); 171 maxLapTime = Math.max(maxLapTime, currentLapTime); 172 } 173 174 return maxLapTime; 175 } 176 177 /** 178 * In practice, {@code time} can be any value due to device reboots. When the real-time clock is 179 * reset, there is no more guarantee that this time falls after the last recorded lap. 180 * 181 * @param time a point in time expected, but not required, to be after the end of the prior lap 182 * @return the elapsed time between the given {@code time} and the end of the prior lap; 183 * negative elapsed times are normalized to {@code 0} 184 */ getCurrentLapTime(long time)185 long getCurrentLapTime(long time) { 186 final Lap previousLap = getLaps().get(0); 187 final long currentLapTime = time - previousLap.getAccumulatedTime(); 188 return Math.max(0, currentLapTime); 189 } 190 191 /** 192 * Updates the notification to reflect the latest state of the stopwatch and recorded laps. 193 */ updateNotification()194 void updateNotification() { 195 final Stopwatch stopwatch = getStopwatch(); 196 197 // Notification should be hidden if the stopwatch has no time or the app is open. 198 if (stopwatch.isReset() || mNotificationModel.isApplicationInForeground()) { 199 mNotificationManager.cancel(mNotificationModel.getStopwatchNotificationId()); 200 return; 201 } 202 203 @StringRes final int eventLabel = R.string.label_notification; 204 205 // Intent to load the app when the notification is tapped. 206 final Intent showApp = new Intent(mContext, HandleDeskClockApiCalls.class) 207 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 208 .setAction(HandleDeskClockApiCalls.ACTION_SHOW_STOPWATCH) 209 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel); 210 211 final PendingIntent pendingShowApp = PendingIntent.getActivity(mContext, 0, showApp, 212 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT); 213 214 // Compute some values required below. 215 final boolean running = stopwatch.isRunning(); 216 final String pname = mContext.getPackageName(); 217 final Resources res = mContext.getResources(); 218 final long base = SystemClock.elapsedRealtime() - stopwatch.getTotalTime(); 219 220 final RemoteViews collapsed = new RemoteViews(pname, R.layout.stopwatch_notif_collapsed); 221 collapsed.setChronometer(R.id.swn_collapsed_chronometer, base, null, running); 222 collapsed.setOnClickPendingIntent(R.id.swn_collapsed_hitspace, pendingShowApp); 223 collapsed.setImageViewResource(R.id.notification_icon, R.drawable.stat_notify_stopwatch); 224 225 final RemoteViews expanded = new RemoteViews(pname, R.layout.stopwatch_notif_expanded); 226 expanded.setChronometer(R.id.swn_expanded_chronometer, base, null, running); 227 expanded.setOnClickPendingIntent(R.id.swn_expanded_hitspace, pendingShowApp); 228 expanded.setImageViewResource(R.id.notification_icon, R.drawable.stat_notify_stopwatch); 229 230 @IdRes final int leftButtonId = R.id.swn_left_button; 231 @IdRes final int rightButtonId = R.id.swn_right_button; 232 if (running) { 233 // Left button: Pause 234 expanded.setTextViewText(leftButtonId, res.getText(R.string.sw_pause_button)); 235 setTextViewDrawable(expanded, leftButtonId, R.drawable.ic_pause_24dp); 236 final Intent pause = new Intent(mContext, StopwatchService.class) 237 .setAction(HandleDeskClockApiCalls.ACTION_PAUSE_STOPWATCH) 238 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel); 239 expanded.setOnClickPendingIntent(leftButtonId, pendingServiceIntent(pause)); 240 241 // Right button: Add Lap 242 if (canAddMoreLaps()) { 243 expanded.setTextViewText(rightButtonId, res.getText(R.string.sw_lap_button)); 244 setTextViewDrawable(expanded, rightButtonId, R.drawable.ic_sw_lap_24dp); 245 246 final Intent lap = new Intent(mContext, StopwatchService.class) 247 .setAction(HandleDeskClockApiCalls.ACTION_LAP_STOPWATCH) 248 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel); 249 expanded.setOnClickPendingIntent(rightButtonId, pendingServiceIntent(lap)); 250 expanded.setViewVisibility(rightButtonId, VISIBLE); 251 } else { 252 expanded.setViewVisibility(rightButtonId, INVISIBLE); 253 } 254 255 // Show the current lap number if any laps have been recorded. 256 final int lapCount = getLaps().size(); 257 if (lapCount > 0) { 258 final int lapNumber = lapCount + 1; 259 final String lap = res.getString(R.string.sw_notification_lap_number, lapNumber); 260 collapsed.setTextViewText(R.id.swn_collapsed_laps, lap); 261 collapsed.setViewVisibility(R.id.swn_collapsed_laps, VISIBLE); 262 expanded.setTextViewText(R.id.swn_expanded_laps, lap); 263 expanded.setViewVisibility(R.id.swn_expanded_laps, VISIBLE); 264 } else { 265 collapsed.setViewVisibility(R.id.swn_collapsed_laps, GONE); 266 expanded.setViewVisibility(R.id.swn_expanded_laps, GONE); 267 } 268 } else { 269 // Left button: Start 270 expanded.setTextViewText(leftButtonId, res.getText(R.string.sw_start_button)); 271 setTextViewDrawable(expanded, leftButtonId, R.drawable.ic_start_24dp); 272 final Intent start = new Intent(mContext, StopwatchService.class) 273 .setAction(HandleDeskClockApiCalls.ACTION_START_STOPWATCH) 274 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel); 275 expanded.setOnClickPendingIntent(leftButtonId, pendingServiceIntent(start)); 276 277 // Right button: Reset (HandleDeskClockApiCalls will also bring forward the app) 278 expanded.setViewVisibility(rightButtonId, VISIBLE); 279 expanded.setTextViewText(rightButtonId, res.getText(R.string.sw_reset_button)); 280 setTextViewDrawable(expanded, rightButtonId, R.drawable.ic_reset_24dp); 281 final Intent reset = new Intent(mContext, HandleDeskClockApiCalls.class) 282 .setAction(HandleDeskClockApiCalls.ACTION_RESET_STOPWATCH) 283 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel); 284 expanded.setOnClickPendingIntent(rightButtonId, pendingActivityIntent(reset)); 285 286 // Indicate the stopwatch is paused. 287 collapsed.setTextViewText(R.id.swn_collapsed_laps, res.getString(R.string.swn_paused)); 288 collapsed.setViewVisibility(R.id.swn_collapsed_laps, VISIBLE); 289 expanded.setTextViewText(R.id.swn_expanded_laps, res.getString(R.string.swn_paused)); 290 expanded.setViewVisibility(R.id.swn_expanded_laps, VISIBLE); 291 } 292 293 // Swipe away will reset the stopwatch without bringing forward the app. 294 final Intent reset = new Intent(mContext, StopwatchService.class) 295 .setAction(HandleDeskClockApiCalls.ACTION_RESET_STOPWATCH) 296 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel); 297 298 final Notification notification = new NotificationCompat.Builder(mContext) 299 .setLocalOnly(true) 300 .setOngoing(running) 301 .setContent(collapsed) 302 .setAutoCancel(stopwatch.isPaused()) 303 .setPriority(Notification.PRIORITY_MAX) 304 .setDeleteIntent(pendingServiceIntent(reset)) 305 .setSmallIcon(R.drawable.ic_tab_stopwatch_activated) 306 .build(); 307 notification.bigContentView = expanded; 308 mNotificationManager.notify(mNotificationModel.getStopwatchNotificationId(), notification); 309 } 310 pendingServiceIntent(Intent intent)311 private PendingIntent pendingServiceIntent(Intent intent) { 312 return PendingIntent.getService(mContext, 0, intent, FLAG_UPDATE_CURRENT); 313 } 314 pendingActivityIntent(Intent intent)315 private PendingIntent pendingActivityIntent(Intent intent) { 316 return PendingIntent.getActivity(mContext, 0, intent, FLAG_UPDATE_CURRENT); 317 } 318 setTextViewDrawable(RemoteViews rv, int viewId, int drawableId)319 private static void setTextViewDrawable(RemoteViews rv, int viewId, int drawableId) { 320 rv.setTextViewCompoundDrawablesRelative(viewId, drawableId, 0, 0, 0); 321 } 322 getMutableLaps()323 private List<Lap> getMutableLaps() { 324 if (mLaps == null) { 325 mLaps = StopwatchDAO.getLaps(mContext); 326 } 327 328 return mLaps; 329 } 330 331 /** 332 * Update the stopwatch notification in response to a locale change. 333 */ 334 private final class LocaleChangedReceiver extends BroadcastReceiver { 335 @Override onReceive(Context context, Intent intent)336 public void onReceive(Context context, Intent intent) { 337 updateNotification(); 338 } 339 } 340 }