com.actinarium.nagbox.service.NagboxService.java Source code

Java tutorial

Introduction

Here is the source code for com.actinarium.nagbox.service.NagboxService.java

Source

/*
 * Copyright (C) 2016 Actinarium
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.actinarium.nagbox.service;

import android.app.AlarmManager;
import android.app.IntentService;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.sqlite.SQLiteDatabase;
import android.os.Build;
import android.support.v4.content.WakefulBroadcastReceiver;
import android.text.format.DateUtils;
import android.util.Log;
import com.actinarium.nagbox.database.NagboxContract;
import com.actinarium.nagbox.database.NagboxContract.TasksTable;
import com.actinarium.nagbox.database.NagboxDbHelper;
import com.actinarium.nagbox.database.NagboxDbOps;
import com.actinarium.nagbox.model.Task;

import java.util.ArrayList;
import java.util.List;

/**
 * An intent service that handles task operations and alarm management.
 *
 * @author Paul Danyliuk
 */
public class NagboxService extends IntentService {

    private static final String TAG = "NagboxService";

    public static final String ACTION_CREATE_TASK = "com.actinarium.nagbox.intent.action.CREATE_TASK";
    public static final String ACTION_UPDATE_TASK = "com.actinarium.nagbox.intent.action.UPDATE_TASK";
    public static final String ACTION_UPDATE_TASK_STATUS = "com.actinarium.nagbox.intent.action.UPDATE_TASK_STATUS";
    public static final String ACTION_DELETE_TASK = "com.actinarium.nagbox.intent.action.DELETE_TASK";
    public static final String ACTION_RESTORE_TASK = "com.actinarium.nagbox.intent.action.RESTORE_TASK";

    // These can only be triggered within the system (have no corresponding public ways to call them)
    static final String ACTION_ON_ALARM_FIRED = "com.actinarium.nagbox.intent.action.ON_ALARM_FIRED";
    static final String ACTION_ON_NOTIFICATION_DISMISSED = "com.actinarium.nagbox.intent.action.ON_NOTIFICATION_DISMISSED";
    static final String ACTION_ON_NOTIFICATION_ACTION_STOP_TASK = "com.actinarium.nagbox.intent.action.ON_NOTIFICATION_ACTION_STOP_TASK";

    static final String EXTRA_TASK = "com.actinarium.nagbox.intent.extra.TASK";
    static final String EXTRA_TASK_ID = "com.actinarium.nagbox.intent.extra.TASK_ID";
    static final String EXTRA_CANCEL_NOTIFICATION_ID = "com.actinarium.nagbox.intent.extra.EXTRA_CANCEL_NOTIFICATION_ID";

    private static final long ALARM_TOLERANCE = 5 * DateUtils.SECOND_IN_MILLIS;

    /**
     * Our writable database. Since we need it literally everywhere, it makes sense to pull it only once in onCreate().
     */
    private SQLiteDatabase mDatabase;

    /**
     * Create a new unstarted task. Doesn't trigger rescheduling alarms.
     *
     * @param context context
     * @param task    task to create
     */
    public static void createTask(Context context, Task task) {
        Intent intent = new Intent(context, NagboxService.class);
        intent.setAction(ACTION_CREATE_TASK);
        intent.putExtra(EXTRA_TASK, task);
        context.startService(intent);
    }

    /**
     * Update task description. Doesn't update the flags (i.e. doesn't start or stop the task), and as of now it doesn't
     * reschedule next alarm either. If you need to update task status, use {@link #updateTaskStatus(Context, Task)}.
     * {@link Task#id} must be set.
     *
     * @param context context
     * @param task    task to update
     */
    public static void updateTask(Context context, Task task) {
        Intent intent = new Intent(context, NagboxService.class);
        intent.setAction(ACTION_UPDATE_TASK);
        intent.putExtra(EXTRA_TASK, task);
        context.startService(intent);
    }

    /**
     * Update task status (flags). Use this to start or stop the task. {@link Task#id} must be set. Will result in
     * rescheduling the alarm to closer time if needed.
     *
     * @param context context
     * @param task    task to update its flags
     */
    public static void updateTaskStatus(Context context, Task task) {
        Intent intent = new Intent(context, NagboxService.class);
        intent.setAction(ACTION_UPDATE_TASK_STATUS);
        intent.putExtra(EXTRA_TASK, task);
        context.startService(intent);
    }

    /**
     * Delete the task entirely. Will trigger rescheduling the alarm to later time if needed, or cancelling it.
     *
     * @param context context
     * @param taskId  ID of the task to delete
     */
    public static void deleteTask(Context context, long taskId) {
        Intent intent = new Intent(context, NagboxService.class);
        intent.setAction(ACTION_DELETE_TASK);
        intent.putExtra(EXTRA_TASK_ID, taskId);
        context.startService(intent);
    }

    /**
     * Same as {@link #createTask(Context, Task)}, but will insert the task with its old ID and trigger rescheduling the
     * alarm.
     *
     * @param context context
     * @param task    task to restore
     */
    public static void restoreTask(Context context, Task task) {
        Intent intent = new Intent(context, NagboxService.class);
        intent.setAction(ACTION_RESTORE_TASK);
        intent.putExtra(EXTRA_TASK, task);
        context.startService(intent);
    }

    public NagboxService() {
        super(TAG);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        mDatabase = NagboxDbHelper.getInstance(this).getWritableDatabase();
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        if (intent == null) {
            return;
        }

        // I know that only either of those is needed, but for the sake of nice code I'm pulling these here
        final Task task = intent.getParcelableExtra(EXTRA_TASK);
        final long id = intent.getLongExtra(EXTRA_TASK_ID, Task.NO_ID);
        switch (intent.getAction()) {
        case ACTION_UPDATE_TASK_STATUS:
            handleUpdateTaskStatus(task);
            break;
        case ACTION_ON_ALARM_FIRED:
            handleOnAlarmFired();
            break;
        case ACTION_ON_NOTIFICATION_DISMISSED:
            handleOnNotificationDismissed(id);
            break;
        case ACTION_ON_NOTIFICATION_ACTION_STOP_TASK:
            int notificationIdToCancel = intent.getIntExtra(EXTRA_CANCEL_NOTIFICATION_ID, -1);
            handleStopTaskById(id, notificationIdToCancel);
            break;
        case ACTION_CREATE_TASK:
            handleCreateTask(task);
            break;
        case ACTION_UPDATE_TASK:
            handleUpdateTask(task);
            break;
        case ACTION_DELETE_TASK:
            handleDeleteTask(id);
            break;
        case ACTION_RESTORE_TASK:
            handleRestoreTask(task);
            break;
        }

        // Release the wake lock, if there was any.
        WakefulBroadcastReceiver.completeWakefulIntent(intent);
    }

    private void handleCreateTask(Task task) {
        // Our app must ensure that task order is correct and unique. So assign the order = max(order) + 1
        // We could (and should) do this atomically using INSERT with sub-query, but that's not trivial with given APIs.
        int maxOrder = NagboxDbOps.getMaxTaskOrder(mDatabase);
        task.displayOrder = maxOrder + 1;

        // In the end of the method we put everything into the DB using DbOps.Transaction
        boolean isSuccess = NagboxDbOps.startTransaction(mDatabase).createTask(task).commit();

        // Process transaction result.
        // If successful, you still need to notify the cursor so that any loaders that listen to this data would reload
        if (isSuccess) {
            getContentResolver().notifyChange(TasksTable.CONTENT_URI, null);
        } else {
            Log.e(TAG, "Couldn't create task " + task);
        }
    }

    private void handleUpdateTask(Task task) {
        if (task.id < 0) {
            Log.e(TAG, "Was trying to update task with invalid/unset ID=" + task.id);
            return;
        }

        boolean isSuccess = NagboxDbOps.startTransaction(mDatabase).updateTask(task).commit();

        if (isSuccess) {
            // Even though our content provider doesn't know about a single item URI yet, won't hurt to do it right
            getContentResolver().notifyChange(TasksTable.getUriForItem(task.id), null);
        } else {
            Log.e(TAG, "Couldn't update task " + task);
        }
    }

    private void handleUpdateTaskStatus(Task task) {
        if (task.id < 0) {
            Log.e(TAG, "Was trying to update flags of the task with invalid/unset ID=" + task.id);
            return;
        }

        boolean isSuccess = NagboxDbOps.startTransaction(mDatabase).updateTaskStatus(task).commit();

        if (isSuccess) {
            getContentResolver().notifyChange(TasksTable.getUriForItem(task.id), null);
            rescheduleAlarm();
        } else {
            Log.e(TAG, "Couldn't update status of task " + task);
        }
    }

    private void handleStopTaskById(long taskId, int notificationIdToCancel) {
        if (notificationIdToCancel != -1) {
            NotificationHelper.cancelNotification(this, notificationIdToCancel);
        }

        // Request partial task model - only status columns (id, flags, next timestamp) are needed
        Task task = NagboxDbOps.getTaskStatusById(mDatabase, taskId, NagboxContract.TASK_STATUS_PROJECTION);

        if (task == null || !task.isActive()) {
            // Nothing to update
            return;
        }

        task.setIsActive(false);
        task.setIsSeen(true);
        handleUpdateTaskStatus(task);
    }

    private void handleDeleteTask(long taskId) {
        if (taskId < 0) {
            Log.e(TAG, "Was trying to delete task with invalid ID=" + taskId);
            return;
        }

        boolean isSuccess = NagboxDbOps.startTransaction(mDatabase).deleteTask(taskId).commit();

        if (isSuccess) {
            getContentResolver().notifyChange(TasksTable.getUriForItem(taskId), null);
            rescheduleAlarm();
        } else {
            Log.e(TAG, "Couldn't delete task with ID " + taskId);
        }
    }

    private void handleRestoreTask(Task task) {
        // Restoring is the same as creating, and our task already has an order field set correctly, and an ID to notify
        boolean isSuccess = NagboxDbOps.startTransaction(mDatabase).createTask(task).commit();

        if (isSuccess) {
            getContentResolver().notifyChange(TasksTable.getUriForItem(task.id), null);
            rescheduleAlarm();
        } else {
            Log.e(TAG, "Couldn't restore task " + task);
        }
    }

    private void rescheduleAlarm() {
        AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);

        // Prepare pending intent. Setting, updating, or cancelling the alarm - we need it in either case
        Intent intent = new Intent(this, NagAlarmReceiver.class);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent,
                PendingIntent.FLAG_UPDATE_CURRENT);

        long nextTimestamp = NagboxDbOps.getClosestNagTimestamp(mDatabase);
        if (nextTimestamp == 0) {
            alarmManager.cancel(pendingIntent);
        } else {
            // todo: deal with exact/inexact reminders later
            if (Build.VERSION.SDK_INT >= 23) {
                alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTimestamp, pendingIntent);
            } else if (Build.VERSION.SDK_INT >= 19) {
                alarmManager.setWindow(AlarmManager.RTC_WAKEUP, nextTimestamp, ALARM_TOLERANCE, pendingIntent);
            } else {
                alarmManager.set(AlarmManager.RTC_WAKEUP, nextTimestamp, pendingIntent);
            }
        }
    }

    private void handleOnAlarmFired() {
        final long now = System.currentTimeMillis();

        Task[] tasksToRemind = NagboxDbOps.getTasksToRemind(mDatabase, now);
        if (tasksToRemind.length == 0) {
            Log.i(TAG, "Alarm fired/check requested, but there was nothing to remind about");
            return;
        }

        NotificationHelper.fireNotification(this, tasksToRemind);

        // Update the status and the time of the next fire where needed.
        List<Task> tasksToUpdate = new ArrayList<>(tasksToRemind.length);
        for (Task task : tasksToRemind) {
            boolean isModified = false;
            if (task.isSeen()) {
                task.setIsSeen(false);
                isModified = true;
            }

            // Using the loop because the alarm might've fired long ago (e.g. before system reboot),
            // so we need to make sure that nextFireAt is indeed in the future
            while (task.nextFireAt <= now) {
                task.nextFireAt += task.interval * DateUtils.MINUTE_IN_MILLIS;
                isModified = true;
            }

            if (isModified) {
                tasksToUpdate.add(task);
            }
        }

        final int updateSize = tasksToUpdate.size();
        if (updateSize == 0) {
            Log.w(TAG, "Strangely enough, there was nothing to update when alarm fired");
            rescheduleAlarm();
            return;
        }

        // Otherwise update the tasks that need it
        NagboxDbOps.Transaction transaction = NagboxDbOps.startTransaction(mDatabase);
        for (int i = 0; i < updateSize; i++) {
            final Task task = tasksToUpdate.get(i);
            transaction.updateTaskStatus(task);
        }
        boolean isSuccess = transaction.commit();

        if (!isSuccess) {
            Log.e(TAG, "Couldn't update status of the tasks when alarm fired");
        } else {
            // Notify all affected task items
            final ContentResolver contentResolver = getContentResolver();
            for (int i = 0; i < updateSize; i++) {
                contentResolver.notifyChange(TasksTable.getUriForItem(tasksToUpdate.get(i).id), null);
            }
        }

        // Finally, schedule the alarm to fire the next time it's ought to fire
        rescheduleAlarm();
    }

    /**
     * Unset "not seen" flag from the task with provided ID or all unseen tasks (depending on whether a summary
     * notification or an individual one from the stack was dismissed)
     *
     * @param id ID of the task that's "seen". Pass {@link Task#NO_ID} to "see" all tasks
     */
    private void handleOnNotificationDismissed(long id) {
        Task[] tasksToDismiss = NagboxDbOps.getTasksToDismiss(mDatabase, id);

        if (tasksToDismiss.length == 0) {
            // Well, nothing to do. Maybe the user has deactivated the tasks before dismissing the notification
            return;
        }

        NagboxDbOps.Transaction transaction = NagboxDbOps.startTransaction(mDatabase);
        for (Task task : tasksToDismiss) {
            task.setIsSeen(true);
            transaction.updateTaskStatus(task);
        }
        boolean isSuccess = transaction.commit();

        if (!isSuccess) {
            Log.e(TAG, "Couldn't unset the 'not seen' flag from tasks");
        } else {
            // Notify all affected task items
            final ContentResolver contentResolver = getContentResolver();
            for (Task task : tasksToDismiss) {
                contentResolver.notifyChange(TasksTable.getUriForItem(task.id), null);
            }
        }
    }

}