com.finchuk.clock2.chronometer.ChronometerNotificationService.java Source code

Java tutorial

Introduction

Here is the source code for com.finchuk.clock2.chronometer.ChronometerNotificationService.java

Source

/*
 * Copyright 2017 Oleksandr Finchuk
 *
 * This file is part of ClockPlus.
 *
 * ClockPlus is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * ClockPlus is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with ClockPlus.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.finchuk.clock2.chronometer;

import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.support.annotation.CallSuper;
import android.support.annotation.DrawableRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.util.SimpleArrayMap;

/**
 * Created by Oleksandr Finchuk on 07/06/2017.
 */
public abstract class ChronometerNotificationService extends Service {
    public static final String ACTION_START_PAUSE = "com.finchuk.clock2.timers.action.START_PAUSE";
    public static final String ACTION_STOP = "com.finchuk.clock2.timers.action.STOP";

    public static final String EXTRA_ACTION_ID = "com.finchuk.clock2.extra.ID";

    // TODO: I think we'll need a collection of builders too. However, we can have a common immutable
    // builder instance with attributes that all timer notifications will have.
    //    private NotificationCompat.Builder mNoteBuilder;
    private NotificationManager mNotificationManager;
    /**
     * The default capacity of an array map is 0.
     * The minimum amount by which the capacity of a ArrayMap will increase
     * is currently {@link SimpleArrayMap#BASE_SIZE 4}.
     */
    private final SimpleArrayMap<Long, NotificationCompat.Builder> mNoteBuilders = new SimpleArrayMap<>();
    private final SimpleArrayMap<Long, ChronometerNotificationThread> mThreads = new SimpleArrayMap<>();
    private final SimpleArrayMap<Long, ChronometerDelegate> mDelegates = new SimpleArrayMap<>();

    /**
     * @return the icon for the notification
     */
    @DrawableRes
    protected abstract int getSmallIcon();

    /**
     * @return an optional content intent that is fired when the notification is clicked
     */
    @Nullable
    protected abstract PendingIntent getContentIntent();

    /**
     * @return whether the chronometer should be counting down
     */
    protected abstract boolean isCountDown();

    /**
     * @return the id for the foreground notification, if {@link #isForeground()} returns true.
     * Otherwise, this value will not be considered for anything.
     */
    protected abstract int getNoteId();

    /**
     * @return an optional tag associated with the notification(s). The default implementation
     * returns null if {@link #isForeground()} returns true; otherwise, it returns the class's name.
     */
    protected String getNoteTag() {
        return isForeground() ? null : getClass().getName();
    }

    /**
     * @return whether this service should run in the foreground. The default is true.
     */
    protected boolean isForeground() {
        return true;
    }

    /**
     * The intent received in {@link #onStartCommand(Intent, int, int)}
     * has no {@link Intent#getAction() action} set. At this point, you
     * should configure the notification to be displayed.
     * @param intent
     * @param flags
     * @param startId
     */
    protected abstract void handleDefaultAction(Intent intent, int flags, int startId);

    protected abstract void handleStartPauseAction(Intent intent, int flags, int startId);

    protected abstract void handleStopAction(Intent intent, int flags, int startId);

    /**
     * This will be called if the command in {@link #onStartCommand(Intent, int, int)}
     * has an action that your subclass defines.
     * @param action Your custom action.
     * @param startId
     */
    protected abstract void handleAction(@NonNull String action, Intent intent, int flags, int startId);

    @Override
    public void onCreate() {
        super.onCreate();
        mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        if (isForeground()) {
            registerNewNoteBuilder(getNoteId());
            // IGNORE THE LINT WARNING ABOUT UNNECESSARY BOXING. Because getNoteId() returns an int,
            // it gets boxed to an Integer. A Long and an Integer are never interchangeable, even
            // if they wrap the same integer value.
            startForeground(getNoteId(), mNoteBuilders.get(Long.valueOf(getNoteId())).build());
        }
    }

    private void registerNewChronometer(long id) {
        if (mDelegates.containsKey(id))
            return;
        ChronometerDelegate delegate = new ChronometerDelegate();
        delegate.init();
        delegate.setCountDown(isCountDown());
        mDelegates.put(id, delegate);
    }

    /**
     * If a notification builder is not already registered with the provided ID,
     * then register a new instance. Each new builder comes with a new chronometer.
     */
    protected final void registerNewNoteBuilder(long id) {
        // If we didn't have this check, then we'd be replacing a previous
        // builder with a new instance. The problem with that is the new id->builder
        // mapping here will not kept in sync with the builder reference in the
        // ChronometerNotificationThread, unless you make a later call to
        // startNewThread(). If the chronometer represents a Timer and it gets paused,
        // quitCurrentThread() is called, but startNewThread() will NOT be called.
        // In quitCurrentThread(), we tell the thread to update the notification for us
        // prior to actually quiting. This is where the problem manifests: because the
        // thread is holding onto the previous builder instance, updating the notification
        // means the old builder's attributes will be used to build a new notification.
        // When we later make attribute changes to the builder via e.g. addAction(),
        // we are actually affecting the new builder instance registered here,
        // not the old instance held by the thread.
        //
        // We could have avoided this out-of-sync issue by updating the notification
        // in this class, using getNoteTag() and the id passed to quitCurrentThread(),
        // instead of telling the thread to do it for us.
        if (mNoteBuilders.containsKey(id))
            return;
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this).setSmallIcon(getSmallIcon())
                .setShowWhen(false).setOngoing(true).setContentIntent(getContentIntent());
        mNoteBuilders.put(id, builder);
        registerNewChronometer(id);
    }

    // Didn't work!
    //    @Override
    //    public void onTrimMemory(int level) {
    //        if (level >= TRIM_MEMORY_BACKGROUND) {
    //            Log.d("ChronomNotifService", "Stopping foreground");
    //            // The penultimate trim level, indicates the process is around the
    //            // middle of the background LRU list.
    //            // If we didn't call this, we would continue to run in the foreground
    //            // until we get killed, and the notification would be removed with it.
    //            // We want to keep the notification alive even if the process is killed,
    //            // so the user can still be aware of the stopwatch.
    //            stopForeground(true);
    //            // Post it again, but outside of the foreground state.
    //            updateNotification(true);
    //        }
    //    }

    @Override
    public void onDestroy() {
        for (int i = 0; i < mThreads.size(); i++) {
            // TOneverDO: quitCurrentThread() because that posts the notification again
            quitThread(mThreads.keyAt(i));
        }
    }

    @CallSuper
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null) {
            final String action = intent.getAction();
            if (action != null) {
                switch (action) {
                case ACTION_START_PAUSE:
                    handleStartPauseAction(intent, flags, startId);
                    break;
                case ACTION_STOP:
                    handleStopAction(intent, flags, startId);
                    break;
                default:
                    // Defer to the subclass
                    handleAction(action, intent, flags, startId);
                    break;
                }
            } else {
                handleDefaultAction(intent, flags, startId);
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Nullable
    @Override
    public final IBinder onBind(Intent intent) {
        return null;
    }

    /**
     * If there is a thread currently running, then this will push any notification updates
     * you might have configured in the Builder and then call the thread's {@link
     * ChronometerNotificationThread#quit() quit()}.
     * @param id the id associated with the thread to quit
     */
    public void quitCurrentThread(long id) {
        ChronometerNotificationThread thread = mThreads.get(id);
        if (thread != null) {
            // Display any notification updates associated with the current state
            // of the chronometer. If we relied on the HandlerThread to do this for us,
            // the message delivery would be delayed.
            // TODO: We could update the notification ourselves using getNoteTag() and
            // the id param. This is desirable if we ever encounter other "out-of-sync"
            // issues regarding the notification builder references between this class
            // and the thread. See #registerNewNoteBuilder() for more info.
            thread.updateNotification(false/*updateText*/);
            // If the chronometer has been set to not run, the effect is obvious.
            // Otherwise, we're preparing for the start of a new thread.
            quitThread(id);
        }
    }

    /**
     * Instantiates a new HandlerThread and calls its {@link Thread#start() start()}.
     * The calling thread will be blocked until the HandlerThread created here finishes
     * initializing its looper.
     * @param id
     * @param base the new base time of the chronometer
     */
    public void startNewThread(long id, long base) {
        // 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.
        ChronometerNotificationThread thread = new ChronometerNotificationThread(mDelegates.get(id),
                mNotificationManager, mNoteBuilders.get(id), getResources(), getNoteTag(), (int) id);
        mThreads.put(id, thread);
        // 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.
        thread.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.
        thread.getLooper();
        // -------------------------------------------------------------------------------
        // TOneverDO: Set base BEFORE the thread is ready to begin working, or else when
        // the thread actually begins working, it will initially show that some time has
        // passed.
        ChronometerDelegate delegate = mDelegates.get(id);
        delegate.setBase(base);
        // -------------------------------------------------------------------------------
    }

    /**
     * Releases all resources associated with this id. This is only
     * necessary for subclasses that support multiple notifications,
     * because they don't have the convenience of stopping the service
     * altogether to GC all resources.
     */
    @CallSuper
    protected void releaseResources(long id) {
        mNoteBuilders.remove(id);
        quitThread(id);
        mThreads.remove(id);
        mDelegates.remove(id);
    }

    /**
     * Helper method to add the start/pause action to the notification's builder.
     * @param running whether the chronometer is running
     * @param id The id of the notification that the action should be added to.
     *           Will be used as an integer request code to create the PendingIntent that
     *           is fired when this action is clicked.
     */
    protected final void addStartPauseAction(boolean running, long id) {
        addAction(ACTION_START_PAUSE,
                running ? com.finchuk.clock2.R.drawable.ic_pause_24dp : com.finchuk.clock2.R.drawable.ic_start_24dp,
                getString(running ? com.finchuk.clock2.R.string.pause : com.finchuk.clock2.R.string.resume), id);
    }

    /**
     * Helper method to add the stop action to the notification's builder.
     * @param id The id of the notification that the action should be added to.
     *           Will be used as an integer request code to create the PendingIntent that
     *           is fired when this action is clicked.
     */
    protected final void addStopAction(long id) {
        addAction(ACTION_STOP, com.finchuk.clock2.R.drawable.ic_stop_24dp,
                getString(com.finchuk.clock2.R.string.stop), id);
    }

    /**
     * Clear the notification builder's set actions.
     * @param id the id associated with the builder whose actions should be cleared
     */
    protected final void clearActions(long id) {
        // 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.
        mNoteBuilders.get(id).mActions.clear();
    }

    /**
     * @param id The id associated with the chronometer that you wish to modify.
     */
    protected final void setBase(long id, long base) {
        mDelegates.get(id).setBase(base);
    }

    /**
     * @param id The id associated with the chronometer that you wish to modify.
     */
    protected final long getBase(long id) {
        return mDelegates.get(id).getBase();
    }

    /**
     * @param id The id associated with the thread that should update the notification.
     */
    protected final void updateNotification(long id, boolean updateText) {
        mThreads.get(id).updateNotification(updateText);
    }

    /**
     * @param id The id associated with the builder that should update its content title.
     */
    protected final void setContentTitle(long id, CharSequence title) {
        mNoteBuilders.get(id).setContentTitle(title);
    }

    /**
     * Adds the specified action to the notification's Builder.
     * @param id The id of the notification that the action should be added to.
     *           Will be used as an integer request code to create the PendingIntent that
     *           is fired when this action is clicked.
     */
    protected final void addAction(String action, @DrawableRes int icon, String actionTitle, long id) {
        Intent intent = new Intent(this, getClass()).setAction(action).putExtra(EXTRA_ACTION_ID, id);
        PendingIntent pi = PendingIntent.getService(this, (int) id, intent, 0/*no flags*/);
        mNoteBuilders.get(id).addAction(icon, actionTitle, pi);
    }

    /**
     * Cancels the notification with the pair ({@link #getNoteTag() tag}, id)
     */
    protected final void cancelNotification(long id/*TODO: change to int noteId?*/) {
        mNotificationManager.cancel(getNoteTag(), (int) id);
    }

    /**
     * Causes the handler thread's looper to terminate without processing
     * any more messages in the message queue.
     */
    private void quitThread(long id) {
        ChronometerNotificationThread thread = mThreads.get(id);
        if (thread != null && thread.isAlive()) {
            thread.quit();
        }
    }
}