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
17package com.android.deskclock.data;
18
19import android.app.Notification;
20import android.app.PendingIntent;
21import android.content.BroadcastReceiver;
22import android.content.Context;
23import android.content.Intent;
24import android.content.IntentFilter;
25import android.content.res.Resources;
26import android.os.SystemClock;
27import android.support.annotation.IdRes;
28import android.support.annotation.StringRes;
29import android.support.v4.app.NotificationCompat;
30import android.support.v4.app.NotificationManagerCompat;
31import android.widget.RemoteViews;
32
33import com.android.deskclock.HandleDeskClockApiCalls;
34import com.android.deskclock.R;
35import com.android.deskclock.stopwatch.StopwatchService;
36
37import java.util.Collections;
38import java.util.List;
39
40import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
41import static android.view.View.GONE;
42import static android.view.View.INVISIBLE;
43import static android.view.View.VISIBLE;
44
45/**
46 * All {@link Stopwatch} data is accessed via this model.
47 */
48final 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
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     */
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     */
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     */
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     */
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     */
143    void clearLaps() {
144        StopwatchDAO.clearLaps(mContext);
145        getMutableLaps().clear();
146    }
147
148    /**
149     * @return {@code true} iff more laps can be recorded
150     */
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     */
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     */
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     */
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
311    private PendingIntent pendingServiceIntent(Intent intent) {
312        return PendingIntent.getService(mContext, 0, intent, FLAG_UPDATE_CURRENT);
313    }
314
315    private PendingIntent pendingActivityIntent(Intent intent) {
316        return PendingIntent.getActivity(mContext, 0, intent, FLAG_UPDATE_CURRENT);
317    }
318
319    private static void setTextViewDrawable(RemoteViews rv, int viewId, int drawableId) {
320        rv.setTextViewCompoundDrawablesRelative(viewId, drawableId, 0, 0, 0);
321    }
322
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
336        public void onReceive(Context context, Intent intent) {
337            updateNotification();
338        }
339    }
340}