com.battlelancer.seriesguide.service.NotificationService.java Source code

Java tutorial

Introduction

Here is the source code for com.battlelancer.seriesguide.service.NotificationService.java

Source

/*
 * Copyright 2014 Uwe Trottmann
 *
 * 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.battlelancer.seriesguide.service;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.AlarmManager;
import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Typeface;
import android.net.Uri;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.TaskStackBuilder;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.style.StyleSpan;
import com.battlelancer.seriesguide.R;
import com.battlelancer.seriesguide.provider.SeriesGuideContract.Episodes;
import com.battlelancer.seriesguide.provider.SeriesGuideContract.Shows;
import com.battlelancer.seriesguide.provider.SeriesGuideDatabase.Tables;
import com.battlelancer.seriesguide.settings.DisplaySettings;
import com.battlelancer.seriesguide.settings.NotificationSettings;
import com.battlelancer.seriesguide.thetvdbapi.TheTVDB;
import com.battlelancer.seriesguide.ui.EpisodesActivity;
import com.battlelancer.seriesguide.ui.QuickCheckInActivity;
import com.battlelancer.seriesguide.ui.ShowsActivity;
import com.battlelancer.seriesguide.util.ServiceUtils;
import com.battlelancer.seriesguide.util.TimeTools;
import com.battlelancer.seriesguide.util.Utils;
import com.squareup.picasso.Picasso;
import com.uwetrottmann.androidutils.AndroidUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import timber.log.Timber;

public class NotificationService extends IntentService {

    private static final String KEY_EPISODE_CLEARED_TIME = "com.battlelancer.seriesguide.episode_cleared_time";

    private static final boolean DEBUG = false;

    private static final int REQUEST_CODE_DELETE_INTENT = 1;

    private static final int REQUEST_CODE_SINGLE_EPISODE = 2;

    private static final int REQUEST_CODE_MULTIPLE_EPISODES = 3;

    private static final int REQUEST_CODE_ACTION_CHECKIN = 4;

    private static final long[] VIBRATION_PATTERN = new long[] { 0, 100, 200, 100, 100, 100 };

    private static final String[] PROJECTION = new String[] { Tables.EPISODES + "." + Episodes._ID, Episodes.TITLE,
            Episodes.FIRSTAIREDMS, Shows.TITLE, Shows.NETWORK, Episodes.NUMBER, Episodes.SEASON, Shows.POSTER,
            Episodes.OVERVIEW };

    // by airdate, then by show, then lowest number first
    private static final String SORTING = Episodes.FIRSTAIREDMS + " ASC," + Shows.TITLE + " ASC," + Episodes.NUMBER
            + " ASC";

    // only within time frame, unwatched episodes
    private static final String SELECTION = Episodes.FIRSTAIREDMS + ">=? AND " + Episodes.SELECTION_UNWATCHED;

    interface NotificationQuery {

        int _ID = 0;

        int TITLE = 1;

        int EPISODE_FIRST_RELEASE_MS = 2;

        int SHOW_TITLE = 3;

        int NETWORK = 4;

        int NUMBER = 5;

        int SEASON = 6;

        int POSTER = 7;

        int OVERVIEW = 8;
    }

    public NotificationService() {
        super("Episode Notification Service");
        setIntentRedelivery(true);
    }

    @SuppressLint("CommitPrefEdits")
    @TargetApi(android.os.Build.VERSION_CODES.KITKAT)
    @Override
    protected void onHandleIntent(Intent intent) {
        Timber.d("Waking up...");
        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);

        /*
         * Handle a possible delete intent.
         */
        if (handleDeleteIntent(this, intent)) {
            return;
        }

        /*
         * Unschedule notification service wake-ups for disabled notifications
         * and non-supporters.
         */
        if (!NotificationSettings.isNotificationsEnabled(this) || !Utils.hasAccessToX(this)) {
            Timber.d("Notification service disabled, removing wakup-up alarm");
            // cancel any pending alarm
            AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
            Intent i = new Intent(this, OnAlarmReceiver.class);
            PendingIntent pi = PendingIntent.getBroadcast(this, 0, i, 0);
            am.cancel(pi);

            resetLastEpisodeAirtime(prefs);

            return;
        }

        long wakeUpTime = 0;

        /*
         * Get pool of episodes which air from 12 hours ago until eternity which
         * match the users settings.
         */
        StringBuilder selection = new StringBuilder(SELECTION);
        boolean isFavsOnly = NotificationSettings.isNotifyAboutFavoritesOnly(this);
        Timber.d("Do notify about " + (isFavsOnly ? "favorites ONLY" : "ALL"));
        if (isFavsOnly) {
            selection.append(" AND ").append(Shows.SELECTION_FAVORITES);
        }
        boolean isNoSpecials = DisplaySettings.isHidingSpecials(this);
        Timber.d("Do " + (isNoSpecials ? "NOT " : "") + "notify about specials");
        if (isNoSpecials) {
            selection.append(" AND ").append(Episodes.SELECTION_NO_SPECIALS);
        }
        // always exclude hidden shows
        selection.append(" AND ").append(Shows.SELECTION_NO_HIDDEN);

        final long customCurrentTime = TimeTools.getCurrentTime(this);
        final Cursor upcomingEpisodes = getContentResolver().query(Episodes.CONTENT_URI_WITHSHOW, PROJECTION,
                selection.toString(),
                new String[] { String.valueOf(customCurrentTime - 12 * DateUtils.HOUR_IN_MILLIS) }, SORTING);

        if (upcomingEpisodes != null) {
            int notificationThreshold = NotificationSettings.getLatestToIncludeTreshold(this);
            if (DEBUG) {
                Timber.d("DEBUG MODE: notification threshold is 1 week");
                // a week, for debugging (use only one show to get single
                // episode notifications)
                notificationThreshold = 10080;
                // notify again for same episodes
                resetLastEpisodeAirtime(prefs);
            }

            final long nextEpisodeReleaseTime = NotificationSettings.getNextToNotifyAbout(this);
            // wake user-defined amount of time earlier than next episode release time
            final long plannedWakeUpTime = TimeTools.getEpisodeReleaseTime(this, nextEpisodeReleaseTime).getTime()
                    - DateUtils.MINUTE_IN_MILLIS * notificationThreshold;

            /*
             * Set to -1 as on first run nextTimePlanned will be 0. This assures
             * we still see notifications of upcoming episodes then.
             */
            int newEpisodesAvailable = -1;

            // Check if we did wake up earlier than planned
            if (System.currentTimeMillis() < plannedWakeUpTime) {
                Timber.d("Woke up earlier than planned, checking for new episodes");
                newEpisodesAvailable = 0;
                long latestTimeNotified = NotificationSettings.getLastNotified(this);

                // Check if there are any earlier episodes to notify about
                while (upcomingEpisodes.moveToNext()) {
                    final long releaseTime = upcomingEpisodes.getLong(NotificationQuery.EPISODE_FIRST_RELEASE_MS);
                    if (releaseTime < nextEpisodeReleaseTime) {
                        if (releaseTime > latestTimeNotified) {
                            /**
                             * This will not get new episodes which would have
                             * aired the same time as the last one we notified
                             * about. Sad, but the best we can do right now.
                             */
                            newEpisodesAvailable = 1;
                            break;
                        }
                    } else {
                        break;
                    }
                }
            }

            if (newEpisodesAvailable == 0) {
                // Go to sleep, wake up as planned
                Timber.d("No new episodes, going to sleep.");
                wakeUpTime = plannedWakeUpTime;
            } else {
                // Get episodes which are within the notification threshold
                // (user set) and not yet cleared
                final List<Integer> notifyPositions = new ArrayList<>();
                final long latestTimeCleared = NotificationSettings.getLastCleared(this);
                final long latestTimeToInclude = customCurrentTime
                        + DateUtils.MINUTE_IN_MILLIS * notificationThreshold;

                int position = -1;
                upcomingEpisodes.moveToPosition(position);
                while (upcomingEpisodes.moveToNext()) {
                    position++;

                    final long releaseTime = upcomingEpisodes.getLong(NotificationQuery.EPISODE_FIRST_RELEASE_MS);
                    if (releaseTime <= latestTimeToInclude) {
                        /*
                         * Only add those after the last one the user cleared.
                         * At most those of the last 24 hours (see query above).
                         */
                        if (releaseTime > latestTimeCleared) {
                            notifyPositions.add(position);
                        }
                    } else {
                        // Too far into the future, stop!
                        break;
                    }
                }

                // Notify if we found any episodes
                if (notifyPositions.size() > 0) {
                    // store latest air time of all episodes we notified about
                    upcomingEpisodes.moveToPosition(notifyPositions.get(notifyPositions.size() - 1));
                    long latestAirtime = upcomingEpisodes.getLong(NotificationQuery.EPISODE_FIRST_RELEASE_MS);
                    if (!AndroidUtils.isHoneycombOrHigher()) {
                        /*
                         * Everything below HC does not have delete intents, so
                         * we just never notify about the same episode twice.
                         */
                        Timber.d("Delete intent NOT supported, setting last cleared to: " + latestAirtime);
                        prefs.edit().putLong(NotificationSettings.KEY_LAST_CLEARED, latestAirtime).commit();
                    }
                    Timber.d("Found " + notifyPositions.size() + " new episodes, setting last notified to: "
                            + latestAirtime);
                    prefs.edit().putLong(NotificationSettings.KEY_LAST_NOTIFIED, latestAirtime).commit();

                    onNotify(upcomingEpisodes, notifyPositions, latestAirtime);
                }

                /*
                 * Plan next episode to notify about, calc wake-up alarm as
                 * early as user wants.
                 */
                upcomingEpisodes.moveToPosition(-1);
                while (upcomingEpisodes.moveToNext()) {
                    final long releaseTime = upcomingEpisodes.getLong(NotificationQuery.EPISODE_FIRST_RELEASE_MS);
                    if (releaseTime > latestTimeToInclude) {
                        // store next episode we plan to notify about
                        Timber.d("Storing next episode time to notify about: " + releaseTime);
                        prefs.edit().putLong(NotificationSettings.KEY_NEXT_TO_NOTIFY, releaseTime).commit();

                        // calc actual wake up time
                        wakeUpTime = TimeTools.getEpisodeReleaseTime(this, releaseTime).getTime()
                                - DateUtils.MINUTE_IN_MILLIS * notificationThreshold;

                        break;
                    }
                }
            }

            upcomingEpisodes.close();
        }

        // Set a default wake-up time if there are no future episodes for now
        if (wakeUpTime <= 0) {
            wakeUpTime = System.currentTimeMillis() + 6 * DateUtils.HOUR_IN_MILLIS;
            Timber.d("No future episodes found, wake up in 6 hours");
        }

        AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
        Intent i = new Intent(this, NotificationService.class);
        PendingIntent pi = PendingIntent.getService(this, 0, i, 0);
        Timber.d("Going to sleep, setting wake-up alarm to: " + wakeUpTime);
        if (AndroidUtils.isKitKatOrHigher()) {
            am.setExact(AlarmManager.RTC_WAKEUP, wakeUpTime, pi);
        } else {
            am.set(AlarmManager.RTC_WAKEUP, wakeUpTime, pi);
        }
    }

    /**
     * Extracts the last cleared time and stores it in settings.
     */
    public static boolean handleDeleteIntent(Context context, Intent intent) {
        long clearedTime = intent.getLongExtra(KEY_EPISODE_CLEARED_TIME, 0);
        if (clearedTime != 0) {
            // Never show the cleared episode(s) again
            Timber.d("Notification cleared, setting last cleared episode time: " + clearedTime);
            PreferenceManager.getDefaultSharedPreferences(context).edit()
                    .putLong(NotificationSettings.KEY_LAST_CLEARED, clearedTime).commit();
            return true;
        }
        return false;
    }

    /**
     * Resets the air time of the last notified about episode. Afterwards notifications for
     * episodes
     * may appear, which were already notified about.
     */
    @SuppressLint("CommitPrefEdits")
    public static void resetLastEpisodeAirtime(final SharedPreferences prefs) {
        Timber.d("Resetting last cleared and last notified");
        prefs.edit().putLong(NotificationSettings.KEY_LAST_CLEARED, 0)
                .putLong(NotificationSettings.KEY_LAST_NOTIFIED, 0).commit();
    }

    private void onNotify(final Cursor upcomingEpisodes, List<Integer> notifyPositions, long latestAirtime) {
        final Context context = getApplicationContext();

        CharSequence tickerText;
        CharSequence contentTitle;
        CharSequence contentText;
        PendingIntent contentIntent;
        // base intent for task stack
        final Intent showsIntent = new Intent(context, ShowsActivity.class);
        showsIntent.putExtra(ShowsActivity.InitBundle.SELECTED_TAB, ShowsActivity.InitBundle.INDEX_TAB_UPCOMING);

        final int count = notifyPositions.size();
        if (count == 1) {
            // notify in detail about one episode
            Timber.d("Notifying about 1 new episode");
            upcomingEpisodes.moveToPosition(notifyPositions.get(0));

            final String showTitle = upcomingEpisodes.getString(NotificationQuery.SHOW_TITLE);
            tickerText = getString(R.string.upcoming_show, showTitle);
            contentTitle = showTitle + " "
                    + Utils.getEpisodeNumber(this, upcomingEpisodes.getInt(NotificationQuery.SEASON),
                            upcomingEpisodes.getInt(NotificationQuery.NUMBER));

            // "8:00 PM on Network"
            final String releaseTime = TimeTools.formatToLocalReleaseTime(this, TimeTools.getEpisodeReleaseTime(
                    this, upcomingEpisodes.getLong(NotificationQuery.EPISODE_FIRST_RELEASE_MS)));
            final String network = upcomingEpisodes.getString(NotificationQuery.NETWORK);
            contentText = getString(R.string.upcoming_show_detailed, releaseTime, network);

            Intent episodeDetailsIntent = new Intent(context, EpisodesActivity.class);
            episodeDetailsIntent.putExtra(EpisodesActivity.InitBundle.EPISODE_TVDBID,
                    upcomingEpisodes.getInt(NotificationQuery._ID));
            episodeDetailsIntent.putExtra(KEY_EPISODE_CLEARED_TIME, latestAirtime);

            contentIntent = TaskStackBuilder.create(context).addNextIntent(showsIntent)
                    .addNextIntent(episodeDetailsIntent)
                    .getPendingIntent(REQUEST_CODE_SINGLE_EPISODE, PendingIntent.FLAG_CANCEL_CURRENT);
        } else {
            // notify about multiple episodes
            Timber.d("Notifying about " + count + " new episodes");
            tickerText = getString(R.string.upcoming_episodes);
            contentTitle = getString(R.string.upcoming_episodes_number, count);
            contentText = getString(R.string.upcoming_display);

            contentIntent = TaskStackBuilder.create(context)
                    .addNextIntent(showsIntent.putExtra(KEY_EPISODE_CLEARED_TIME, latestAirtime))
                    .getPendingIntent(REQUEST_CODE_MULTIPLE_EPISODES, PendingIntent.FLAG_CANCEL_CURRENT);
        }

        final NotificationCompat.Builder nb = new NotificationCompat.Builder(context);

        if (AndroidUtils.isJellyBeanOrHigher()) {
            Timber.d("Building rich notification (JB+)");
            // JELLY BEAN and above
            if (count == 1) {
                // single episode
                upcomingEpisodes.moveToPosition(notifyPositions.get(0));
                maybeSetPoster(context, nb, upcomingEpisodes.getString(NotificationQuery.POSTER));

                final String episodeTitle = upcomingEpisodes.getString(NotificationQuery.TITLE);
                final String episodeSummary = upcomingEpisodes.getString(NotificationQuery.OVERVIEW);

                final SpannableStringBuilder bigText = new SpannableStringBuilder();
                bigText.append(TextUtils.isEmpty(episodeTitle) ? "" : episodeTitle);
                bigText.setSpan(new StyleSpan(Typeface.BOLD), 0, bigText.length(), 0);
                bigText.append("\n");
                bigText.append(TextUtils.isEmpty(episodeSummary) ? "" : episodeSummary);

                nb.setStyle(new NotificationCompat.BigTextStyle().bigText(bigText).setSummaryText(contentText));

                // Action button to check in
                Intent checkInActionIntent = new Intent(context, QuickCheckInActivity.class);
                checkInActionIntent.putExtra(QuickCheckInActivity.InitBundle.EPISODE_TVDBID,
                        upcomingEpisodes.getInt(NotificationQuery._ID));
                checkInActionIntent
                        .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
                PendingIntent checkInIntent = PendingIntent.getActivity(context, REQUEST_CODE_ACTION_CHECKIN,
                        checkInActionIntent, PendingIntent.FLAG_CANCEL_CURRENT);
                nb.addAction(R.drawable.ic_action_checkin, getString(R.string.checkin), checkInIntent);
            } else {
                // multiple episodes
                NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();

                // display at most the first five
                for (int displayIndex = 0; displayIndex < Math.min(count, 5); displayIndex++) {
                    if (!upcomingEpisodes.moveToPosition(notifyPositions.get(displayIndex))) {
                        // could not go to the desired position (testing just in case)
                        break;
                    }

                    final SpannableStringBuilder lineText = new SpannableStringBuilder();

                    // show title
                    String showTitle = upcomingEpisodes.getString(NotificationQuery.SHOW_TITLE);
                    lineText.append(TextUtils.isEmpty(showTitle) ? "" : showTitle);
                    lineText.setSpan(new StyleSpan(Typeface.BOLD), 0, lineText.length(), 0);

                    lineText.append(" ");

                    // "8:00 PM on Network"
                    String releaseTime = TimeTools.formatToLocalReleaseTime(this, TimeTools.getEpisodeReleaseTime(
                            this, upcomingEpisodes.getLong(NotificationQuery.EPISODE_FIRST_RELEASE_MS)));
                    String network = upcomingEpisodes.getString(NotificationQuery.NETWORK);
                    lineText.append(getString(R.string.upcoming_show_detailed, releaseTime, network));

                    inboxStyle.addLine(lineText);
                }

                // tell if we could not display all episodes
                if (count > 5) {
                    inboxStyle.setSummaryText(getString(R.string.more, count - 5));
                }

                nb.setStyle(inboxStyle);
                nb.setContentInfo(String.valueOf(count));
            }
        } else {
            // ICS and below
            if (count == 1) {
                // single episode
                upcomingEpisodes.moveToPosition(notifyPositions.get(0));
                maybeSetPoster(context, nb, upcomingEpisodes.getString(NotificationQuery.POSTER));
            }
        }

        // notification sound
        final String ringtoneUri = NotificationSettings.getNotificationsRingtone(context);
        // If the string is empty, the user chose silent...
        if (ringtoneUri.length() != 0) {
            // ...otherwise set the specified ringtone
            Timber.d("Notification has sound");
            nb.setSound(Uri.parse(ringtoneUri));
        }
        // vibration
        if (NotificationSettings.isNotificationVibrating(context)) {
            Timber.d("Notification vibrates");
            nb.setVibrate(VIBRATION_PATTERN);
        }
        nb.setDefaults(Notification.DEFAULT_LIGHTS);
        nb.setWhen(System.currentTimeMillis());
        nb.setAutoCancel(true);
        nb.setTicker(tickerText);
        nb.setContentTitle(contentTitle);
        nb.setContentText(contentText);
        nb.setContentIntent(contentIntent);
        nb.setSmallIcon(R.drawable.ic_notification);
        nb.setColor(getResources().getColor(R.color.accent_primary));
        nb.setPriority(NotificationCompat.PRIORITY_DEFAULT);
        nb.setCategory(NotificationCompat.CATEGORY_EVENT);

        Timber.d("Setting delete intent with episode time: " + latestAirtime);
        Intent i = new Intent(this, NotificationService.class);
        i.putExtra(KEY_EPISODE_CLEARED_TIME, latestAirtime);
        PendingIntent deleteIntent = PendingIntent.getService(this, REQUEST_CODE_DELETE_INTENT, i,
                PendingIntent.FLAG_CANCEL_CURRENT);
        nb.setDeleteIntent(deleteIntent);

        // build the notification
        Notification notification = nb.build();

        // use string resource id, always unique within app
        final NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        nm.notify(R.string.upcoming_show, notification);
    }

    private void maybeSetPoster(Context context, NotificationCompat.Builder nb, String posterPath) {
        try {
            Bitmap poster = ServiceUtils.getPicasso(context).load(TheTVDB.buildPosterUrl(posterPath)).centerCrop()
                    .resizeDimen(R.dimen.show_poster_width, R.dimen.show_poster_height).get();
            nb.setLargeIcon(poster);
        } catch (IOException e) {
            Timber.e(e, "Failed to load poster for notification: " + posterPath);
        }
    }
}