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.stopwatch;
18 
19 import android.content.ActivityNotFoundException;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.os.Bundle;
23 import android.os.PowerManager;
24 import android.os.SystemClock;
25 import android.support.v7.widget.LinearLayoutManager;
26 import android.support.v7.widget.RecyclerView;
27 import android.support.v7.widget.SimpleItemAnimator;
28 import android.transition.AutoTransition;
29 import android.transition.Transition;
30 import android.transition.TransitionManager;
31 import android.view.LayoutInflater;
32 import android.view.View;
33 import android.view.ViewGroup;
34 import android.view.accessibility.AccessibilityManager;
35 
36 import com.android.deskclock.DeskClock;
37 import com.android.deskclock.DeskClockFragment;
38 import com.android.deskclock.LogUtils;
39 import com.android.deskclock.R;
40 import com.android.deskclock.data.DataModel;
41 import com.android.deskclock.data.Lap;
42 import com.android.deskclock.data.Stopwatch;
43 import com.android.deskclock.events.Events;
44 import com.android.deskclock.timer.CountingTimerView;
45 
46 import static android.content.Context.ACCESSIBILITY_SERVICE;
47 import static android.content.Context.POWER_SERVICE;
48 import static android.os.PowerManager.ON_AFTER_RELEASE;
49 import static android.os.PowerManager.SCREEN_BRIGHT_WAKE_LOCK;
50 import static android.view.View.GONE;
51 import static android.view.View.INVISIBLE;
52 import static android.view.View.VISIBLE;
53 
54 /**
55  * Fragment that shows the stopwatch and recorded laps.
56  */
57 public final class StopwatchFragment extends DeskClockFragment {
58 
59     private static final String TAG = "StopwatchFragment";
60 
61     /** Scheduled to update the stopwatch time and current lap time while stopwatch is running. */
62     private final Runnable mTimeUpdateRunnable = new TimeUpdateRunnable();
63 
64     /** Used to determine when talk back is on in order to lower the time update rate. */
65     private AccessibilityManager mAccessibilityManager;
66 
67     /** {@code true} while the {@link #mLapsList} is transitioning between shown and hidden. */
68     private boolean mLapsListIsTransitioning;
69 
70     /** The data source for {@link #mLapsList}. */
71     private LapsAdapter mLapsAdapter;
72 
73     /** The layout manager for the {@link #mLapsAdapter}. */
74     private LinearLayoutManager mLapsLayoutManager;
75 
76     /** Draws the reference lap while the stopwatch is running. */
77     private StopwatchCircleView mTime;
78 
79     /** Displays the recorded lap times. */
80     private RecyclerView mLapsList;
81 
82     /** Displays the current stopwatch time. */
83     private CountingTimerView mTimeText;
84 
85     /** Held while the stopwatch is running and this fragment is forward to keep the screen on. */
86     private PowerManager.WakeLock mWakeLock;
87 
88     /** The public no-arg constructor required by all fragments. */
StopwatchFragment()89     public StopwatchFragment() {}
90 
91     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state)92     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state) {
93         mLapsAdapter = new LapsAdapter(getActivity());
94         mLapsLayoutManager = new LinearLayoutManager(getActivity());
95 
96         final View v = inflater.inflate(R.layout.stopwatch_fragment, container, false);
97         mTime = (StopwatchCircleView) v.findViewById(R.id.stopwatch_time);
98         mLapsList = (RecyclerView) v.findViewById(R.id.laps_list);
99         ((SimpleItemAnimator) mLapsList.getItemAnimator()).setSupportsChangeAnimations(false);
100         mLapsList.setLayoutManager(mLapsLayoutManager);
101         mLapsList.setAdapter(mLapsAdapter);
102 
103         // Timer text serves as a virtual start/stop button.
104         mTimeText = (CountingTimerView) v.findViewById(R.id.stopwatch_time_text);
105         mTimeText.setVirtualButtonEnabled(true);
106         mTimeText.registerVirtualButtonAction(new ToggleStopwatchRunnable());
107 
108         return v;
109     }
110 
111     @Override
onActivityCreated(Bundle savedInstanceState)112     public void onActivityCreated(Bundle savedInstanceState) {
113         super.onActivityCreated(savedInstanceState);
114 
115         mAccessibilityManager =
116                 (AccessibilityManager) getActivity().getSystemService(ACCESSIBILITY_SERVICE);
117     }
118 
119     @Override
onResume()120     public void onResume() {
121         super.onResume();
122 
123         // Conservatively assume the data in the adapter has changed while the fragment was paused.
124         mLapsAdapter.notifyDataSetChanged();
125 
126         // Update the state of the buttons.
127         setFabAppearance();
128         setLeftRightButtonAppearance();
129 
130         // Draw the current stopwatch and lap times.
131         updateTime();
132 
133         // Start updates if the stopwatch is running; blink text if it is paused.
134         switch (getStopwatch().getState()) {
135             case RUNNING:
136                 acquireWakeLock();
137                 mTime.update();
138                 startUpdatingTime();
139                 break;
140             case PAUSED:
141                 mTimeText.blinkTimeStr(true);
142                 break;
143         }
144 
145         // Adjust the visibility of the list of laps.
146         showOrHideLaps(false);
147 
148         // Start watching for page changes away from this fragment.
149         getDeskClock().registerPageChangedListener(this);
150 
151         // View is hidden in onPause, make sure it is visible now.
152         final View view = getView();
153         if (view != null) {
154             view.setVisibility(VISIBLE);
155         }
156     }
157 
158     @Override
onPause()159     public void onPause() {
160         super.onPause();
161 
162         final View view = getView();
163         if (view != null) {
164             // Make the view invisible because when the lock screen is activated, the window stays
165             // active under it. Later, when unlocking the screen, we see the old stopwatch time for
166             // a fraction of a second.
167             getView().setVisibility(INVISIBLE);
168         }
169 
170         // Stop all updates while the fragment is not visible.
171         stopUpdatingTime();
172         mTimeText.blinkTimeStr(false);
173 
174         // Stop watching for page changes away from this fragment.
175         getDeskClock().unregisterPageChangedListener(this);
176 
177         // Release the wake lock if it is currently held.
178         releaseWakeLock();
179     }
180 
181     @Override
onPageChanged(int page)182     public void onPageChanged(int page) {
183         if (page == DeskClock.STOPWATCH_TAB_INDEX && getStopwatch().isRunning()) {
184             acquireWakeLock();
185         } else {
186             releaseWakeLock();
187         }
188     }
189 
190     @Override
onFabClick(View view)191     public void onFabClick(View view) {
192         toggleStopwatchState();
193     }
194 
195     @Override
onLeftButtonClick(View view)196     public void onLeftButtonClick(View view) {
197         switch (getStopwatch().getState()) {
198             case RUNNING:
199                 doAddLap();
200                 break;
201             case PAUSED:
202                 doReset();
203                 break;
204         }
205     }
206 
207     @Override
onRightButtonClick(View view)208     public void onRightButtonClick(View view) {
209         doShare();
210     }
211 
212     @Override
setFabAppearance()213     public void setFabAppearance() {
214         if (mFab == null || getSelectedTab() != DeskClock.STOPWATCH_TAB_INDEX) {
215             return;
216         }
217 
218         if (getStopwatch().isRunning()) {
219             mFab.setImageResource(R.drawable.ic_pause_white_24dp);
220             mFab.setContentDescription(getString(R.string.sw_pause_button));
221         } else {
222             mFab.setImageResource(R.drawable.ic_start_white_24dp);
223             mFab.setContentDescription(getString(R.string.sw_start_button));
224         }
225         mFab.setVisibility(VISIBLE);
226     }
227 
228     @Override
setLeftRightButtonAppearance()229     public void setLeftRightButtonAppearance() {
230         if (mLeftButton == null || mRightButton == null ||
231                 getSelectedTab() != DeskClock.STOPWATCH_TAB_INDEX) {
232             return;
233         }
234 
235         mRightButton.setImageResource(R.drawable.ic_share);
236         mRightButton.setContentDescription(getString(R.string.sw_share_button));
237 
238         switch (getStopwatch().getState()) {
239             case RESET:
240                 mLeftButton.setEnabled(false);
241                 mLeftButton.setVisibility(INVISIBLE);
242                 mRightButton.setVisibility(INVISIBLE);
243                 break;
244             case RUNNING:
245                 mLeftButton.setImageResource(R.drawable.ic_lap);
246                 mLeftButton.setContentDescription(getString(R.string.sw_lap_button));
247                 mLeftButton.setEnabled(canRecordMoreLaps());
248                 mLeftButton.setVisibility(canRecordMoreLaps() ? VISIBLE : INVISIBLE);
249                 mRightButton.setVisibility(INVISIBLE);
250                 break;
251             case PAUSED:
252                 mLeftButton.setEnabled(true);
253                 mLeftButton.setImageResource(R.drawable.ic_reset);
254                 mLeftButton.setContentDescription(getString(R.string.sw_reset_button));
255                 mLeftButton.setVisibility(VISIBLE);
256                 mRightButton.setVisibility(VISIBLE);
257                 break;
258         }
259     }
260 
261     /**
262      * Start the stopwatch.
263      */
doStart()264     private void doStart() {
265         Events.sendStopwatchEvent(R.string.action_start, R.string.label_deskclock);
266 
267         // Update the stopwatch state.
268         DataModel.getDataModel().startStopwatch();
269 
270         // Start UI updates.
271         startUpdatingTime();
272         mTime.update();
273         mTimeText.blinkTimeStr(false);
274 
275         // Update button states.
276         setFabAppearance();
277         setLeftRightButtonAppearance();
278 
279         // Acquire the wake lock.
280         acquireWakeLock();
281     }
282 
283     /**
284      * Pause the stopwatch.
285      */
doPause()286     private void doPause() {
287         Events.sendStopwatchEvent(R.string.action_pause, R.string.label_deskclock);
288 
289         // Update the stopwatch state
290         DataModel.getDataModel().pauseStopwatch();
291 
292         // Redraw the paused stopwatch time.
293         updateTime();
294 
295         // Stop UI updates.
296         stopUpdatingTime();
297         mTimeText.blinkTimeStr(true);
298 
299         // Update button states.
300         setFabAppearance();
301         setLeftRightButtonAppearance();
302 
303         // Release the wake lock.
304         releaseWakeLock();
305     }
306 
307     /**
308      * Reset the stopwatch.
309      */
doReset()310     private void doReset() {
311         Events.sendStopwatchEvent(R.string.action_reset, R.string.label_deskclock);
312 
313         // Update the stopwatch state.
314         DataModel.getDataModel().resetStopwatch();
315 
316         // Clear the laps.
317         showOrHideLaps(true);
318 
319         // Clear the times.
320         mTime.postInvalidateOnAnimation();
321         mTimeText.setTime(0, true, true);
322         mTimeText.blinkTimeStr(false);
323 
324         // Update button states.
325         setFabAppearance();
326         setLeftRightButtonAppearance();
327 
328         // Release the wake lock.
329         releaseWakeLock();
330     }
331 
332     /**
333      * Send stopwatch time and lap times to an external sharing application.
334      */
doShare()335     private void doShare() {
336         final String[] subjects = getResources().getStringArray(R.array.sw_share_strings);
337         final String subject = subjects[(int)(Math.random() * subjects.length)];
338         final String text = mLapsAdapter.getShareText();
339 
340         final Intent shareIntent = new Intent(Intent.ACTION_SEND)
341                 .addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET)
342                 .putExtra(Intent.EXTRA_SUBJECT, subject)
343                 .putExtra(Intent.EXTRA_TEXT, text)
344                 .setType("text/plain");
345 
346         final Context context = getActivity();
347         final String title = context.getString(R.string.sw_share_button);
348         final Intent shareChooserIntent = Intent.createChooser(shareIntent, title);
349         try {
350             context.startActivity(shareChooserIntent);
351         } catch (ActivityNotFoundException anfe) {
352             LogUtils.e("No compatible receiver is found");
353         }
354     }
355 
356     /**
357      * Record and add a new lap ending now.
358      */
doAddLap()359     private void doAddLap() {
360         Events.sendStopwatchEvent(R.string.action_lap, R.string.label_deskclock);
361 
362         // Record a new lap.
363         final Lap lap = mLapsAdapter.addLap();
364         if (lap == null) {
365             return;
366         }
367 
368         // Update button states.
369         setLeftRightButtonAppearance();
370 
371         if (lap.getLapNumber() == 1) {
372             // Child views from prior lap sets hang around and blit to the screen when adding the
373             // first lap of the subsequent lap set. Remove those superfluous children here manually
374             // to ensure they aren't seen as the first lap is drawn.
375             mLapsList.removeAllViewsInLayout();
376 
377             // Start animating the reference lap.
378             mTime.update();
379 
380             // Recording the first lap transitions the UI to display the laps list.
381             showOrHideLaps(false);
382         }
383 
384         // Ensure the newly added lap is visible on screen.
385         mLapsList.scrollToPosition(0);
386     }
387 
388     /**
389      * Show or hide the list of laps.
390      */
showOrHideLaps(boolean clearLaps)391     private void showOrHideLaps(boolean clearLaps) {
392         final Transition transition = new AutoTransition()
393                 .addListener(new Transition.TransitionListener() {
394                     @Override
395                     public void onTransitionStart(Transition transition) {
396                         mLapsListIsTransitioning = true;
397                     }
398 
399                     @Override
400                     public void onTransitionEnd(Transition transition) {
401                         mLapsListIsTransitioning = false;
402                     }
403 
404                     @Override
405                     public void onTransitionCancel(Transition transition) {
406                     }
407 
408                     @Override
409                     public void onTransitionPause(Transition transition) {
410                     }
411 
412                     @Override
413                     public void onTransitionResume(Transition transition) {
414                     }
415                 });
416 
417         final ViewGroup sceneRoot = (ViewGroup) getView();
418         TransitionManager.beginDelayedTransition(sceneRoot, transition);
419 
420         if (clearLaps) {
421             mLapsAdapter.clearLaps();
422         }
423 
424         final boolean lapsVisible = mLapsAdapter.getItemCount() > 0;
425         mLapsList.setVisibility(lapsVisible ? VISIBLE : GONE);
426     }
427 
acquireWakeLock()428     private void acquireWakeLock() {
429         if (mWakeLock == null) {
430             final PowerManager pm = (PowerManager) getActivity().getSystemService(POWER_SERVICE);
431             mWakeLock = pm.newWakeLock(SCREEN_BRIGHT_WAKE_LOCK | ON_AFTER_RELEASE, TAG);
432             mWakeLock.setReferenceCounted(false);
433         }
434         mWakeLock.acquire();
435     }
436 
releaseWakeLock()437     private void releaseWakeLock() {
438         if (mWakeLock != null && mWakeLock.isHeld()) {
439             mWakeLock.release();
440         }
441     }
442 
443     /**
444      * Either pause or start the stopwatch based on its current state.
445      */
toggleStopwatchState()446     private void toggleStopwatchState() {
447         if (getStopwatch().isRunning()) {
448             doPause();
449         } else {
450             doStart();
451         }
452     }
453 
getStopwatch()454     private Stopwatch getStopwatch() {
455         return DataModel.getDataModel().getStopwatch();
456     }
457 
canRecordMoreLaps()458     private boolean canRecordMoreLaps() {
459         return DataModel.getDataModel().canAddMoreLaps();
460     }
461 
462     /**
463      * Post the first runnable to update times within the UI. It will reschedule itself as needed.
464      */
startUpdatingTime()465     private void startUpdatingTime() {
466         // Ensure only one copy of the runnable is ever scheduled by first stopping updates.
467         stopUpdatingTime();
468         mTime.post(mTimeUpdateRunnable);
469     }
470 
471     /**
472      * Remove the runnable that updates times within the UI.
473      */
stopUpdatingTime()474     private void stopUpdatingTime() {
475         mTime.removeCallbacks(mTimeUpdateRunnable);
476     }
477 
478     /**
479      * Update all time displays based on a single snapshot of the stopwatch progress. This includes
480      * the stopwatch time drawn in the circle, the current lap time and the total elapsed time in
481      * the list of laps.
482      */
updateTime()483     private void updateTime() {
484         // Compute the total time of the stopwatch.
485         final long totalTime = getStopwatch().getTotalTime();
486 
487         // Update the total time display.
488         mTimeText.setTime(totalTime, true, true);
489 
490         // Update the current lap.
491         final boolean currentLapIsVisible = mLapsLayoutManager.findFirstVisibleItemPosition() == 0;
492         if (!mLapsListIsTransitioning && currentLapIsVisible) {
493             mLapsAdapter.updateCurrentLap(mLapsList, totalTime);
494         }
495     }
496 
497     /**
498      * This runnable periodically updates times throughout the UI. It stops these updates when the
499      * stopwatch is no longer running.
500      */
501     private final class TimeUpdateRunnable implements Runnable {
502         @Override
run()503         public void run() {
504             final long startTime = SystemClock.elapsedRealtime();
505 
506             updateTime();
507 
508             if (getStopwatch().isRunning()) {
509                 // The stopwatch is still running so execute this runnable again after a delay.
510                 final boolean talkBackOn = mAccessibilityManager.isTouchExplorationEnabled();
511 
512                 // Grant longer time between redraws when talk-back is on to let it catch up.
513                 final int period = talkBackOn ? 500 : 25;
514 
515                 // Try to maintain a consistent period of time between redraws.
516                 final long endTime = SystemClock.elapsedRealtime();
517                 final long delay = Math.max(0, startTime + period - endTime);
518 
519                 mTime.postDelayed(this, delay);
520             }
521         }
522     }
523 
524     /**
525      * Tapping the stopwatch text also toggles the stopwatch state, just like the fab.
526      */
527     private final class ToggleStopwatchRunnable implements Runnable {
528         @Override
run()529         public void run() {
530             toggleStopwatchState();
531         }
532     }
533 }
534