Created ChronometerNotificationService and ChronometerNotificationThread. Modify TimerNotificationService to extend from ChronometerNotificationService.

This commit is contained in:
Phillip Hsu
2016-09-11 01:05:03 -07:00
parent 02fbee2f7b
commit 31e0a71d9f
5 changed files with 445 additions and 193 deletions
@@ -2,6 +2,7 @@ package com.philliphsu.clock2.timers;
import android.content.res.Resources;
import android.os.SystemClock;
import android.support.annotation.Nullable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
@@ -80,16 +81,21 @@ public final class ChronometerDelegate {
return mFormat;
}
public CharSequence formatElapsedTime(long now, Resources resources) {
public CharSequence formatElapsedTime(long now, @Nullable Resources resources) {
mNow = now;
long seconds = mCountDown ? mBase - now : now - mBase;
boolean negative = false;
if (seconds < 0) {
// Only modify how negative timers are displayed if we have a Resources.
// Otherwise, if we invert the sign of seconds and we don't have a Resources,
// the timer will be positive again which will confuse the user.
if (seconds < 0 && resources != null) {
seconds = -seconds;
negative = true;
}
String text = DateUtils.formatElapsedTime(mRecycle, seconds / 1000);
if (negative) {
// The only way this can call through is if the previous null check on
// `resources` passed.
text = resources.getString(R.string.negative_duration, text);
}
@@ -1,22 +1,15 @@
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.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.util.Log;
import com.philliphsu.clock2.AsyncTimersTableUpdateHandler;
import com.philliphsu.clock2.ChronometerNotificationService;
import com.philliphsu.clock2.R;
import com.philliphsu.clock2.Timer;
@@ -27,21 +20,16 @@ import com.philliphsu.clock2.Timer;
* a single task and immediately destroy itself, which means we lose all of
* our instance state.
*/
public class TimerNotificationService extends Service {
public class TimerNotificationService extends ChronometerNotificationService {
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";
public static final String ACTION_STOP = "com.philliphsu.clock2.timers.action.STOP";
public static final String EXTRA_TIMER = "com.philliphsu.clock2.timers.extra.TIMER";
private Timer mTimer; // TODO: I think we may need a list of timers.
// TODO: I think we may need a list of timers.
private Timer mTimer;
private TimerController mController;
private NotificationCompat.Builder mNoteBuilder;
private NotificationManager mNotificationManager;
private final ChronometerDelegate mCountdownDelegate = new ChronometerDelegate();
private MyHandlerThread mThread; // TODO: I think we may need a list of threads.
/**
* Helper method to start this Service for its default action: to show
@@ -62,29 +50,23 @@ public class TimerNotificationService extends Service {
* you want to cancel
*/
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);
// TODO: We do this in onDestroy() for a single notification.
// Multiples will probably need something like this.
// NotificationManager nm = (NotificationManager)
// context.getSystemService(Context.NOTIFICATION_SERVICE);
// nm.cancel(getNoteTag(), (int) timerId);
// TODO: We only do this for a single notification. Remove this for multiples.
context.stopService(new Intent(context, TimerNotificationService.class));
// After being cancelled due to time being up, sometimes the active timer notification posts again
// with a static 00:00 text, along with the Time's up notification. My theory is
// our thread has enough leeway to sneak in a final call to post the notification before it
// is actually quit().
// TODO: Write an API in MyHandlerThread that removes its messages. Then, write and
// handle a service command that calls that API. Alternatively, we may not need the
// command because we could just call that API in your quitThread() helper method.
}
@Override
public void onCreate() {
super.onCreate();
mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
protected int getSmallIcon() {
return R.drawable.ic_timer_24dp;
}
// Create base note
mNoteBuilder = new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_timer_24dp)
.setShowWhen(false)
.setOngoing(true);
@Nullable
@Override
protected PendingIntent getContentIntent() {
// TODO: Set content intent so that when clicked, we launch
// TimersFragment and scroll to the given timer id. The following
// is merely pseudocode.
@@ -99,176 +81,86 @@ public class TimerNotificationService extends Service {
// // per notification when retrieving the PendingIntent..
// contentIntent,
// 0/*Shouldn't need a flag..*/));
return null;
}
mCountdownDelegate.init();
mCountdownDelegate.setCountDown(true);
@Override
protected boolean isCountDown() {
return true;
}
@Override
public void onDestroy() {
super.onDestroy();
quitThread();
// After being cancelled due to time being up, sometimes the active timer notification posts again
// with a static 00:00 text, along with the Time's up notification. My theory is
// our thread has enough leeway to sneak in a final call to post the notification before it
// is actually quit().
// As such, try cancelling the notification with this (tag, id) pair again.
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
nm.cancel(getNoteTag(), mTimer.getIntId());
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
final String action = intent.getAction();
if (action == null) {
if ((mTimer = intent.getParcelableExtra(EXTRA_TIMER)) == null) {
throw new IllegalStateException("Cannot start TimerNotificationService without a Timer");
}
mController = new TimerController(mTimer, new AsyncTimersTableUpdateHandler(this, null));
// 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();
} else if (ACTION_START_PAUSE.equals(action)) {
mController.startPause();
syncNotificationWithTimerState(mTimer.isRunning());
} else if (ACTION_STOP.equals(action)) {
mController.stop();
stopSelf();
// We leave removing the notification up to AsyncTimersTableUpdateHandler
// when it calls cancelAlarm() from onPostAsyncUpdate().
}
protected void handleDefaultAction(Intent intent, int flags, long startId) {
if ((mTimer = intent.getParcelableExtra(EXTRA_TIMER)) == null) {
throw new IllegalStateException("Cannot start TimerNotificationService without a Timer");
}
return super.onStartCommand(intent, flags, startId);
mController = new TimerController(mTimer, new AsyncTimersTableUpdateHandler(this, null));
// 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);
}
setContentTitle(title);
syncNotificationWithTimerState(mTimer.isRunning());
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
protected void handleStartPauseAction(Intent intent, int flags, long startId) {
mController.startPause();
syncNotificationWithTimerState(mTimer.isRunning());
}
@Override
protected void handleStopAction(Intent intent, int flags, long startId) {
mController.stop();
stopSelf();
// We leave removing the notification up to AsyncTimersTableUpdateHandler
// when it calls cancelAlarm() from onPostAsyncUpdate().
}
@Override
protected void handleAction(@NonNull String action, Intent intent, int flags, long startId) {
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.
setBase(getBase() + 60000);
updateNotification();
mController.addOneMinute();
} else {
throw new IllegalArgumentException("TimerNotificationService cannot handle action " + action);
}
}
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(ACTION_STOP, R.drawable.ic_stop_24dp, getString(R.string.stop));
// 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();
clearActions();
final int timerId = mTimer.getIntId();
addAction(ACTION_ADD_ONE_MINUTE,
R.drawable.ic_add_24dp,
getString(R.string.minute),
timerId);
addStartPauseAction(running, timerId);
addStopAction(timerId);
quitCurrentThread();
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 mNoteBuilder.
*/
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*/);
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() {
CharSequence text = mCountdownDelegate.formatElapsedTime(SystemClock.elapsedRealtime(), getResources());
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);
}
startNewThread(timerId, SystemClock.elapsedRealtime() + mTimer.timeRemaining());
}
}
}