From df19d6ec4b30ea1a779a4e48af1ba19a97a8acaa Mon Sep 17 00:00:00 2001 From: Phillip Hsu Date: Thu, 8 Sep 2016 19:32:32 -0700 Subject: [PATCH] Implement ticking countdown text for timer notification --- .../clock2/AsyncTimersTableUpdateHandler.java | 7 +- .../clock2/timers/CountdownChronometer.java | 55 +---- .../clock2/timers/CountdownDelegate.java | 81 +++++++ .../timers/TimerNotificationService.java | 220 ++++++++++++++---- 4 files changed, 265 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/philliphsu/clock2/timers/CountdownDelegate.java diff --git a/app/src/main/java/com/philliphsu/clock2/AsyncTimersTableUpdateHandler.java b/app/src/main/java/com/philliphsu/clock2/AsyncTimersTableUpdateHandler.java index dce39dd..0f034fa 100644 --- a/app/src/main/java/com/philliphsu/clock2/AsyncTimersTableUpdateHandler.java +++ b/app/src/main/java/com/philliphsu/clock2/AsyncTimersTableUpdateHandler.java @@ -47,7 +47,12 @@ public final class AsyncTimersTableUpdateHandler extends AsyncDatabaseTableUpdat // will remove and replace it. scheduleAlarm(timer); } else { - cancelAlarm(timer, !timer.hasStarted()); + boolean removeNotification = !timer.hasStarted(); + cancelAlarm(timer, removeNotification); + if (!removeNotification) { + // Post a new notification to reflect the paused state of the timer + TimerNotificationService.showNotification(getContext(), timer); + } } } diff --git a/app/src/main/java/com/philliphsu/clock2/timers/CountdownChronometer.java b/app/src/main/java/com/philliphsu/clock2/timers/CountdownChronometer.java index 2f9bbb8..62f367e 100644 --- a/app/src/main/java/com/philliphsu/clock2/timers/CountdownChronometer.java +++ b/app/src/main/java/com/philliphsu/clock2/timers/CountdownChronometer.java @@ -21,15 +21,10 @@ import android.content.Context; import android.os.Handler; import android.os.Message; import android.os.SystemClock; -import android.text.format.DateUtils; import android.util.AttributeSet; import android.util.Log; import android.widget.TextView; -import java.util.Formatter; -import java.util.IllegalFormatException; -import java.util.Locale; - /** * Created by Phillip Hsu on 7/25/2016. * @@ -52,19 +47,11 @@ public class CountdownChronometer extends TextView { } - private long mBase; - private long mNow; // the currently displayed time private boolean mVisible; private boolean mStarted; private boolean mRunning; - private boolean mLogged; - private String mFormat; - private Formatter mFormatter; - private Locale mFormatterLocale; - private Object[] mFormatterArgs = new Object[1]; - private StringBuilder mFormatBuilder; + private final CountdownDelegate mDelegate = new CountdownDelegate(); private OnChronometerTickListener mOnChronometerTickListener; - private StringBuilder mRecycle = new StringBuilder(8); private static final int TICK_WHAT = 2; @@ -112,8 +99,8 @@ public class CountdownChronometer extends TextView { } private void init() { - mBase = SystemClock.elapsedRealtime(); - updateText(mBase); + mDelegate.init(); + updateText(SystemClock.elapsedRealtime()); } /** @@ -123,7 +110,7 @@ public class CountdownChronometer extends TextView { */ // @android.view.RemotableViewMethod public void setBase(long base) { - mBase = base; + mDelegate.setBase(base); dispatchChronometerTick(); updateText(SystemClock.elapsedRealtime()); } @@ -132,7 +119,7 @@ public class CountdownChronometer extends TextView { * Return the base time as set through {@link #setBase}. */ public long getBase() { - return mBase; + return mDelegate.getBase(); } /** @@ -155,17 +142,14 @@ public class CountdownChronometer extends TextView { */ // @android.view.RemotableViewMethod public void setFormat(String format) { - mFormat = format; - if (format != null && mFormatBuilder == null) { - mFormatBuilder = new StringBuilder(format.length() * 2); - } + mDelegate.setFormat(format); } /** * Returns the current format string as set through {@link #setFormat}. */ public String getFormat() { - return mFormat; + return mDelegate.getFormat(); } /** @@ -236,30 +220,7 @@ public class CountdownChronometer extends TextView { } private synchronized void updateText(long now) { - mNow = now; - long seconds = mBase - now; - seconds /= 1000; - String text = DateUtils.formatElapsedTime(mRecycle, seconds); - - if (mFormat != null) { - Locale loc = Locale.getDefault(); - if (mFormatter == null || !loc.equals(mFormatterLocale)) { - mFormatterLocale = loc; - mFormatter = new Formatter(mFormatBuilder, loc); - } - mFormatBuilder.setLength(0); - mFormatterArgs[0] = text; - try { - mFormatter.format(mFormat, mFormatterArgs); - text = mFormatBuilder.toString(); - } catch (IllegalFormatException ex) { - if (!mLogged) { - Log.w(TAG, "Illegal format string: " + mFormat); - mLogged = true; - } - } - } - setText(text); + setText(mDelegate.formatElapsedTime(now)); } private void updateRunning() { diff --git a/app/src/main/java/com/philliphsu/clock2/timers/CountdownDelegate.java b/app/src/main/java/com/philliphsu/clock2/timers/CountdownDelegate.java new file mode 100644 index 0000000..5ccbb03 --- /dev/null +++ b/app/src/main/java/com/philliphsu/clock2/timers/CountdownDelegate.java @@ -0,0 +1,81 @@ +package com.philliphsu.clock2.timers; + +import android.os.SystemClock; +import android.text.format.DateUtils; +import android.util.Log; + +import java.util.Formatter; +import java.util.IllegalFormatException; +import java.util.Locale; + +/** + * Created by Phillip Hsu on 9/7/2016. + * + * A helper class for CountdownChronometer that handles formatting the countdown text. + * TODO: A similar delegate class can also be made for ChronometerWithMillis. However, try to + * use a common base class between this and ChronometerWithMillis. + */ +final class CountdownDelegate { + private static final String TAG = "CountdownDelegate"; + + private long mBase; + private long mNow; // the currently displayed time + private boolean mLogged; + private String mFormat; + private Formatter mFormatter; + private Locale mFormatterLocale; + private Object[] mFormatterArgs = new Object[1]; + private StringBuilder mFormatBuilder; + private StringBuilder mRecycle = new StringBuilder(8); + + void init() { + mBase = SystemClock.elapsedRealtime(); + } + + void setBase(long base) { + mBase = base; + } + + long getBase() { + return mBase; + } + + void setFormat(String format) { + mFormat = format; + if (format != null && mFormatBuilder == null) { + mFormatBuilder = new StringBuilder(format.length() * 2); + } + } + + String getFormat() { + return mFormat; + } + + String formatElapsedTime(long now) { + mNow = now; + long seconds = mBase - now; + seconds /= 1000; + String text = DateUtils.formatElapsedTime(mRecycle, seconds); + + if (mFormat != null) { + Locale loc = Locale.getDefault(); + if (mFormatter == null || !loc.equals(mFormatterLocale)) { + mFormatterLocale = loc; + mFormatter = new Formatter(mFormatBuilder, loc); + } + mFormatBuilder.setLength(0); + mFormatterArgs[0] = text; + try { + mFormatter.format(mFormat, mFormatterArgs); + text = mFormatBuilder.toString(); + } catch (IllegalFormatException ex) { + if (!mLogged) { + Log.w(TAG, "Illegal format string: " + mFormat); + mLogged = true; + } + } + } + + return text; + } +} diff --git a/app/src/main/java/com/philliphsu/clock2/timers/TimerNotificationService.java b/app/src/main/java/com/philliphsu/clock2/timers/TimerNotificationService.java index 922ace0..9eaab50 100644 --- a/app/src/main/java/com/philliphsu/clock2/timers/TimerNotificationService.java +++ b/app/src/main/java/com/philliphsu/clock2/timers/TimerNotificationService.java @@ -1,17 +1,22 @@ package com.philliphsu.clock2.timers; +import android.annotation.SuppressLint; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; +import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; +import android.os.Message; +import android.os.SystemClock; import android.support.annotation.DrawableRes; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; +import android.util.Log; import com.philliphsu.clock2.AsyncTimersTableUpdateHandler; -import com.philliphsu.clock2.MainActivity; import com.philliphsu.clock2.R; import com.philliphsu.clock2.Timer; @@ -23,7 +28,7 @@ import com.philliphsu.clock2.Timer; * our instance state. */ public class TimerNotificationService extends Service { - private static final String TAG = "TimerNotificationService"; + private static final String TAG = "TimerNotifService"; public static final String ACTION_ADD_ONE_MINUTE = "com.philliphsu.clock2.timers.action.ADD_ONE_MINUTE"; public static final String ACTION_START_PAUSE = "com.philliphsu.clock2.timers.action.START_PAUSE"; @@ -33,6 +38,10 @@ public class TimerNotificationService extends Service { private Timer mTimer; private TimerController mController; + private NotificationCompat.Builder mNoteBuilder; + private NotificationManager mNotificationManager; + private final CountdownDelegate mCountdownDelegate = new CountdownDelegate(); + private MyHandlerThread mThread; /** * Helper method to start this Service for its default action: to show @@ -52,10 +61,46 @@ public class TimerNotificationService extends Service { * @param timerId the id of the Timer associated with the notification * you want to cancel */ - public static void cancelNotification(Context context, long timerId) { - NotificationManager nm = (NotificationManager) - context.getSystemService(Context.NOTIFICATION_SERVICE); - nm.cancel(TAG, (int) timerId); + public static void cancelNotification(Context context, long timerId) { // TODO: remove long param +// NotificationManager nm = (NotificationManager) +// context.getSystemService(Context.NOTIFICATION_SERVICE); +// nm.cancel(TAG, (int) timerId); + context.stopService(new Intent(context, TimerNotificationService.class)); + } + + @Override + public void onCreate() { + super.onCreate(); + mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + + // Create base note + mNoteBuilder = new NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.ic_timer_24dp) + .setShowWhen(false) + .setOngoing(true); + // TODO: Set content intent so that when clicked, we launch + // TimersFragment and scroll to the given timer id. The following + // is merely pseudocode. +// Intent contentIntent = new Intent(this, MainActivity.class); +// contentIntent.putExtra(null/*TODO:MainActivity.EXTRA_SHOW_PAGE*/, 1/*TODO:The tab index of the timers page*/); +// contentIntent.putExtra(null/*TODO:MainActivity.EXTRA_SCROLL_TO_ID*/, mTimer.getId()); +// mNoteBuilder.setContentIntent(PendingIntent.getActivity( +// this, +// 0, // TODO: Request code not needed? Since any multiple notifications +// // should be able to use the same PendingIntent for this action.... +// // unless the underlying *Intent* and its id extra are overwritten +// // per notification when retrieving the PendingIntent.. +// contentIntent, +// 0/*Shouldn't need a flag..*/)); + + mCountdownDelegate.init(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mNotificationManager.cancelAll(); + quitThread(); } @Override @@ -67,14 +112,26 @@ public class TimerNotificationService extends Service { throw new IllegalStateException("Cannot start TimerNotificationService without a Timer"); } mController = new TimerController(mTimer, new AsyncTimersTableUpdateHandler(this, null)); - // TODO: Spawn your own thread to update the countdown text - showNotification(); + // The note's title should change here every time, + // especially if the Timer's label was updated. + String title = mTimer.label(); + if (title.isEmpty()) { + title = getString(R.string.timer); + } + mNoteBuilder.setContentTitle(title); + syncNotificationWithTimerState(mTimer.isRunning()); } else if (ACTION_ADD_ONE_MINUTE.equals(action)) { + // While the notification's countdown would automatically be extended by one minute, + // there is a noticeable delay before the minute gets added on. + // Update the text immediately, because there's no harm in doing so. + mCountdownDelegate.setBase(mCountdownDelegate.getBase() + 60000); + // Dispatch a one-time (non-looping) message so as not to conflate + // with the current set of looping messages. + mThread.sendMessage(MSG_DISPATCH_TICK); mController.addOneMinute(); - // TODO: Verify the notification countdown is extended by one minute. } else if (ACTION_START_PAUSE.equals(action)) { mController.startPause(); - showNotification(); // Update the notification + syncNotificationWithTimerState(mTimer.isRunning()); } else if (ACTION_STOP.equals(action)) { mController.stop(); stopSelf(); @@ -91,57 +148,120 @@ public class TimerNotificationService extends Service { return null; } - private void showNotification() { - // Base note - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_timer_24dp) - .setShowWhen(false) - .setOngoing(true); - // TODO: Set content intent so that when clicked, we launch - // TimersFragment and scroll to the given timer id. The following - // is merely pseudocode. - Intent contentIntent = new Intent(this, MainActivity.class); - contentIntent.putExtra(null/*TODO:MainActivity.EXTRA_SHOW_PAGE*/, 1/*TODO:The tab index of the timers page*/); - contentIntent.putExtra(null/*TODO:MainActivity.EXTRA_SCROLL_TO_ID*/, mTimer.getId()); - builder.setContentIntent(PendingIntent.getActivity( - this, - 0, // TODO: Request code not needed? Since any multiple notifications - // should be able to use the same PendingIntent for this action.... - // unless the underlying *Intent* and its id extra are overwritten - // per notification when retrieving the PendingIntent.. - contentIntent, - 0/*Shouldn't need a flag..*/)); - // TODO: Use a handler to continually update the countdown text - - String title = mTimer.label(); - if (title.isEmpty()) { - title = getString(R.string.timer); - } - builder.setContentTitle(title); - - addAction(builder, ACTION_ADD_ONE_MINUTE, R.drawable.ic_add_24dp, getString(R.string.minute)); - - boolean running = mTimer.isRunning(); - addAction(builder, ACTION_START_PAUSE, + private void syncNotificationWithTimerState(boolean running) { + // The actions from the last time we configured the Builder are still here. + // We have to retain the relative ordering of the actions while updating + // just the start/pause action, so clear them and set them again. + // TODO: The source indicates mActions is hidden, so how are we able to access it? + // Will it remain accessible for all SDK versions? If not, we would have to rebuild + // the entire notification with a new local Builder instance. + mNoteBuilder.mActions.clear(); + addAction(ACTION_ADD_ONE_MINUTE, R.drawable.ic_add_24dp, getString(R.string.minute)); + addAction(ACTION_START_PAUSE, running ? R.drawable.ic_pause_24dp : R.drawable.ic_start_24dp, getString(running ? R.string.pause : R.string.resume)); - addAction(builder, ACTION_STOP, R.drawable.ic_stop_24dp, getString(R.string.stop)); + addAction(ACTION_STOP, R.drawable.ic_stop_24dp, getString(R.string.stop)); - NotificationManager nm = (NotificationManager) - getSystemService(Context.NOTIFICATION_SERVICE); - nm.notify(TAG, mTimer.getIntId(), builder.build()); + // Post the notification immediately, as the HandlerThread will delay its first + // message delivery. + updateNotification(); + // Quit any previously executed thread. If running == false, the effect is obvious; + // otherwise, we're preparing for the start of a new thread. + quitThread(); + + if (running) { + // An instance of Thread cannot be started more than once. You must create + // a new instance if you want to start the Thread's work again. + mThread = new MyHandlerThread(); + // Initializes this thread as a looper. HandlerThread.run() will be executed + // in this thread. + // This gives you a chance to create handlers that then reference this looper, + // before actually starting the loop. + mThread.start(); + // If this thread has been started, this method will block *the calling thread* + // until the looper has been initialized. This ensures the handler thread is + // fully initialized before we proceed. + mThread.getLooper(); + Log.d(TAG, "Looper initialized"); + mCountdownDelegate.setBase(SystemClock.elapsedRealtime() + mTimer.timeRemaining()); + mThread.sendMessage(MSG_WHAT); + } } /** - * Builds and adds the specified action to the notification's builder. + * Builds and adds the specified action to the notification's mNoteBuilder. */ - private void addAction(NotificationCompat.Builder noteBuilder, String action, - @DrawableRes int icon, String actionTitle) { + private void addAction(String action, @DrawableRes int icon, String actionTitle) { Intent intent = new Intent(this, TimerNotificationService.class) .setAction(action); // .putExtra(EXTRA_TIMER, mTimer); PendingIntent pi = PendingIntent.getService(this, mTimer.getIntId(), intent, 0/*no flags*/); - noteBuilder.addAction(icon, actionTitle, pi); + mNoteBuilder.addAction(icon, actionTitle, pi); + } + + /** + * Causes the handler thread's looper to terminate without processing + * any more messages in the message queue. + */ + private void quitThread() { + if (mThread != null && mThread.isAlive()) { + mThread.quit(); + } + } + + private void updateNotification() { + String text = mCountdownDelegate.formatElapsedTime(SystemClock.elapsedRealtime()); + mNoteBuilder.setContentText(text); + mNotificationManager.notify(TAG, mTimer.getIntId(), mNoteBuilder.build()); + } + + private static final int MSG_WHAT = 2; + private static final int MSG_DISPATCH_TICK = 3; + + private class MyHandlerThread extends HandlerThread { + private Handler mHandler; + + public MyHandlerThread() { + super("MyHandlerThread"); + } + + // There won't be a memory leak since our handler is using a looper that is not + // associated with the main thread. The full Lint warning confirmed this. + @SuppressLint("HandlerLeak") + @Override + protected void onLooperPrepared() { + Log.d(TAG, "Looper fully prepared"); + // This is called after the looper has completed initializing, but before + // it starts looping through its message queue. Right now, there is no + // message queue, so this is the place to create it. + // By default, the constructor associates this handler with this thread's looper. + mHandler = new Handler() { + @Override + public void handleMessage(Message m) { + updateNotification(); + if (m.what != MSG_DISPATCH_TICK) { + sendMessageDelayed(Message.obtain(this, MSG_WHAT), 1000); + } + } + }; + } + + public void sendMessage(int what) { + // We've encountered NPEs because the handler was still + // uninitialized even at this point. I assume we cannot rely on any + // defined order in which different threads execute their code. + // Block the calling thread from proceeding until the handler thread + // completes the handler's initialization. + while (mHandler == null); + + Log.d(TAG, "Sending message"); + Message msg = Message.obtain(mHandler, what); + if (what == MSG_DISPATCH_TICK) { + mHandler.sendMessage(msg); + } else if (what == MSG_WHAT) { + mHandler.sendMessageDelayed(msg, 1000); + } + } } } \ No newline at end of file