/*******************************************************************************
* Copyright 2011 The Regents of the University of California
*
* 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 org.ohmage.triggers.notif;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Bundle;
import android.preference.PreferenceManager;
import org.ohmage.R;
import org.ohmage.db.DbHelper;
import org.ohmage.logprobe.Log;
import org.ohmage.logprobe.OhmageAnalytics;
import org.ohmage.logprobe.OhmageAnalytics.TriggerStatus;
import org.ohmage.triggers.base.TriggerDB;
import org.ohmage.triggers.utils.TrigPrefManager;
import java.util.List;
import java.util.Set;
/*
* The trigger notification manager. The logic which displays, repeats and
* removes the home screen notifications are implemented here. Whenever a
* trigger goes off, the base calls the notifyNewTrigger provided by this
* class and passes the notification description as argument. This class
* displays the notification, sets up alarms to expire and repeat the
* notification.
*
* The notifications from all the triggers are summarized into one item
* on the home screen. At any point in time, this notification item will
* display the list of all surveys which are active at that moment.
* Whenever a trigger expires, the list of surveys associated with that
* trigger are removed from the notification item and the surveys list
* in the item is updated with the rest of active surveys if any.
*/
public class Notifier {
private static final String TAG = "Notifier";
//TODO - This needs to be defined in a common place in order
//make sure that it does not collide with any other notification
//id in Ohmage
private static final int NOIF_ID = 100;
//Action of the intent which is broadcasted when the user
//clicks on the notification
private static final String ACTION_TRIGGER_NOTIFICATION =
"org.ohmage.triggers.TRIGGER_NOTIFICATION";
//Action of the intent which is broadcasted when the notification
//is updated by adding or removing surveys
private static final String ACTION_ACTIVE_SURVEY_LIST_CHANGED =
"org.ohmage.triggers.SURVEY_LIST_CHANGED";
private static final String ACTION_NOTIF_CLICKED =
"edu.ucla.cens.triggers.notif.Notifier.notification_clicked";
private static final String ACTION_NOTIF_DELETED =
"edu.ucla.cens.triggers.notif.Notifier.notification_deleted";
private static final String ACTION_EXPIRE_ALM =
"edu.ucla.cens.triggers.notif.Notifier.expire_notif";
private static final String ACTION_REPEAT_ALM =
"edu.ucla.cens.triggers.notif.Notifier.repeat_notif";
private static final String DATA_PREFIX_ALM =
"notifier://edu.ucla.cens.triggers.notif.Notifier/";
private static final String KEY_TRIGGER_ID =
Notifier.class.getName() + ".trigger_id";
private static final String KEY_REPEAT_LIST =
Notifier.class.getName() + ".repeat_list";
private static final String KEY_NOTIF_VISIBILITY_PREF =
"notif_visibility";
public static final String KEY_CAMPAIGN_URN = "campaign_urn";
public static final String KEY_CAMPAIGN_NAME = "campaign_name";
/*
* Utility function to save the status of the notification when it
* is cleared from the home screen. The status is persistently stored
* using shared preferences. This status is used while the notification
* needs to be refreshed 'quietly'. If the notification is already hidden
* and it needs to be refreshed quietly (without alerting the user), no
* action is required.
*/
private static void hideNotification(Context context, String campaignUrn) {
NotificationManager notifMan = (NotificationManager)context.getSystemService(
Context.NOTIFICATION_SERVICE);
notifMan.cancel(campaignUrn.hashCode());
saveNotifVisibility(context, campaignUrn, false);
}
private static void displayNotification(Context context,
String campaignUrn,
String title,
String summary,
boolean quiet) {
/*
* If the notification is to be refreshed quietly, and if it
* is hidden, do nothing.
*/
if(quiet && !getNotifVisibility(context, campaignUrn)) {
return;
}
NotificationManager notifMan = (NotificationManager)context.getSystemService(
Context.NOTIFICATION_SERVICE);
Notification notif = new Notification(R.drawable.survey_notification,
title, System.currentTimeMillis());
if(!quiet) {
SharedPreferences ringtonePrefs = PreferenceManager.getDefaultSharedPreferences(context);
notif.defaults = Notification.DEFAULT_LIGHTS;
notif.sound = Uri.parse(ringtonePrefs.getString("notification_ringtone", RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION).toString()));
long duration = Long.parseLong(ringtonePrefs.getString("notification_vibration", "2000"));
if (duration > 0) {
notif.vibrate = new long [] {0, 200, 200, duration - 800, 200, 200};
}
}
else {
// If it is a quiet update, disable the ticker as well
notif.tickerText = null;
}
//Watch for notification cleared events
notif.deleteIntent = PendingIntent.getBroadcast(context, 0,
new Intent(ACTION_NOTIF_DELETED).putExtra(KEY_CAMPAIGN_URN, campaignUrn).putExtra(KEY_CAMPAIGN_NAME, title).setData(Uri.parse(DATA_PREFIX_ALM + campaignUrn)),
PendingIntent.FLAG_CANCEL_CURRENT);
//Watch for notification clicked events
PendingIntent pi = PendingIntent.getBroadcast(context, 0,
new Intent(ACTION_NOTIF_CLICKED).putExtra(KEY_CAMPAIGN_URN, campaignUrn).putExtra(KEY_CAMPAIGN_NAME, title).setData(Uri.parse(DATA_PREFIX_ALM + campaignUrn)),
PendingIntent.FLAG_CANCEL_CURRENT);
notif.setLatestEventInfo(context, title, summary, pi);
notifMan.notify(campaignUrn.hashCode(), notif);
//Save the current visibility
saveNotifVisibility(context, campaignUrn, true);
}
/*
* Utility function to prepare a string of surveys from a list
* of surveys. This function merely adds commas in between.
*/
private static String getSurveyDisplayList(Set<String> surveys) {
String ret = "";
int i = 0;
for(String survey : surveys) {
ret += survey;
i++;
if(i < surveys.size()) {
ret += ", ";
}
}
return ret;
}
/*
* Refreshes the notification. The caller can specify if the user needs
* to be alerted or the refresh needs to be done quietly. The user
* can be alerted when there is a new trigger or when there is a repeat
* reminder. The notification can be refreshed quietly when a trigger
* expires.
*/
public static void refreshNotification(Context context, String campaignUrn, boolean quiet) {
Log.v(TAG, "Notifier: Refreshing notification, quiet = " + quiet);
//Get the list of all the surveys active at the moment
Set<String> actSurveys = NotifSurveyAdaptor.getAllActiveSurveys(context, campaignUrn);
//Remove the notification if there are no active surveys
if(actSurveys.size() == 0) {
Log.v(TAG, "Notifier: No active surveys");
hideNotification(context, campaignUrn);
}
else {
//Prepare the message and display the notification
String summary = "You have " + actSurveys.size() +
" survey" + (actSurveys.size() != 1 ? "s" : "") +
" to take";
DbHelper dbHelper = new DbHelper(context);
//displayNotification(context, campaignUrn, title, getSurveyDisplayList(actSurveys), quiet);
displayNotification(context, campaignUrn, dbHelper.getCampaign(campaignUrn).mName, summary, quiet);
}
//Send the broadcast indicating a change in the notification survey
//list
context.sendBroadcast(new Intent(ACTION_ACTIVE_SURVEY_LIST_CHANGED).putExtra(KEY_CAMPAIGN_URN, campaignUrn));
}
private static Intent getAlarmIntent(String action, int trigId) {
Intent i = new Intent(action);
i.setData(Uri.parse(DATA_PREFIX_ALM + trigId));
i.putExtra(KEY_TRIGGER_ID, trigId);
return i;
}
private static void cancelAllAlarms(Context context, int trigId) {
AlarmManager alarmMan = (AlarmManager) context.getSystemService(
Context.ALARM_SERVICE);
Intent i = getAlarmIntent(ACTION_EXPIRE_ALM, trigId);
PendingIntent pi = PendingIntent.getBroadcast(context, 0, i,
PendingIntent.FLAG_NO_CREATE);
if(pi != null) {
alarmMan.cancel(pi);
pi.cancel();
}
i = getAlarmIntent(ACTION_REPEAT_ALM, trigId);
pi = PendingIntent.getBroadcast(context, 0, i,
PendingIntent.FLAG_NO_CREATE);
if(pi != null) {
alarmMan.cancel(pi);
pi.cancel();
}
}
private static void setAlarm(Context context,
String action,
int trigId,
int mins,
Bundle extras) {
Log.v(TAG, "Notifier: Setting alarm(" + trigId +
", " + mins + ", " + action + ")");
AlarmManager alarmMan = (AlarmManager)
context.getSystemService(Context.ALARM_SERVICE);
Intent i = getAlarmIntent(action, trigId);
if(extras != null) {
i.putExtras(extras);
}
PendingIntent pi = PendingIntent.getBroadcast(context, 0, i,
PendingIntent.FLAG_CANCEL_CURRENT);
long elapsed = mins * 60 * 1000;
alarmMan.set(AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + elapsed, pi);
}
/*
* Set the alarm for a first item in the repeat reminder list
* of a trigger. The remaining list is bundled with the alarm
* intent. When alarm fires, this function is called again
* with the repeat list obtained from the bundle until there
* are no items remaining in the list.
*
* In order make this algorithm easier, this function accepts
* the repeats as a list of their diffs.
*/
private static void setRepeatAlarm(Context context, int trigId,
int[] repeatDiffs) {
if(repeatDiffs.length == 0) {
//No more repeats in the list
return;
}
//Set a repeat reminder for the first item in the list
//and prepare the new list by removing this item
int[] newRepeats = new int[repeatDiffs.length - 1];
System.arraycopy(repeatDiffs, 1, newRepeats, 0, repeatDiffs.length -1);
Bundle repeatBundle = new Bundle();
repeatBundle.putIntArray(KEY_REPEAT_LIST, newRepeats);
//Set the alarm for the first repeat item and attach the remaining list
setAlarm(context, ACTION_REPEAT_ALM, trigId,
repeatDiffs[0], repeatBundle);
}
/*
* Restores the state of a notification such as repeat reminders and
* expiration timer for a specific trigger. This can be called at
* bootup to restore the notification if it is still valid after
* bootup.
*
* The expiration alarm is restored for the rest of the interval
* calculated using the saved trigger time stamp.
*
* In the case of repeat reminder, only the remaining valid
* reminders are set.
*/
public static void restorePastNotificationStates(Context context,
int trigId,
String notifDesc,
long timeStamp) {
NotifDesc desc = new NotifDesc();
if(!desc.loadString(notifDesc)) {
return;
}
//Cancel all the current alarms
cancelAllAlarms(context, trigId);
//if it hasn't expired yet, create a notif for the remaining time
long now = System.currentTimeMillis();
if(timeStamp > now || timeStamp < 0) {
//TODO log
return;
}
//Calculate the elapsed number of minutes for this trigger
int elapsedMins = (int) (((now - timeStamp) / 1000 ) / 60);
//Calculate the remaining duration for this trigger
int remDuration = desc.getDuration() - elapsedMins;
if(remDuration <= 0) {
//The trigger expired
return;
}
//Set an expire alarm for the remaining duration
setAlarm(context, ACTION_EXPIRE_ALM, trigId,
remDuration, null);
//Set an alarm for the remaining repeats, if any
List<Integer> repeats = desc.getSortedRepeats();
//Check if there is any repeat after the current time
//Older repeats are to be discarded
int i = 0;
for(int repeat : repeats) {
if(repeat > elapsedMins) {
//There are repeats after the current time
//Discard the older ones from the list
int[] repeatDiffs = getRepeatDiffs(repeats.subList(i, repeats.size()));
/* Subtract the elapsed time from the first repeat
* For instance, let the original repeat list be [5, 10, 15].
* Let's assume 7 minutes have elapsed.
* So, the remaining list would be [10, 15] and the diff list
* of this remaining list would be [10, 5]. Now, since 7 minutes
* have already elapsed, the first alarm should be set to fire
* after 3 minutes (10 - 7)
*/
repeatDiffs[0] -= elapsedMins;
setRepeatAlarm(context, trigId, repeatDiffs);
break;
}
i++;
}
}
/*
* Prepare the array of the differences of the repeats from
* the repeat reminders list. The given list must be sorted.
*/
private static int[] getRepeatDiffs(List<Integer> repeatList) {
int[] ret = new int[repeatList.size()];
int i = 0;
for(int repeat : repeatList) {
if(i > 0) {
ret[i] = (repeat - ret[i-1]);
}
else {
ret[i] = repeat;
}
i++;
}
return ret;
}
/*
* Utility function to handle a repeat reminder alarm. Refreshes the
* notification and resets the repeat alarm if required.
*/
private static void repeatReminder(Context context, int trigId, Intent intent) {
TriggerDB db = new TriggerDB(context);
db.open();
String campaignUrn = db.getCampaignUrn(trigId);
db.close();
Set<String> actSurveys = NotifSurveyAdaptor.getActiveSurveysForTrigger(context,
trigId);
//Check if this trigger is still active. If not cancel all the alarms
if(actSurveys.size() == 0) {
cancelAllAlarms(context, trigId);
return;
}
//Trigger is still active, alert the user
refreshNotification(context, campaignUrn, false);
//Continue the remaining repeat reminders
int[] repeatDiffs = intent.getIntArrayExtra(KEY_REPEAT_LIST);
setRepeatAlarm(context, trigId, repeatDiffs);
}
private static void handleNotifClicked(Context context, String campaignUrn) {
//Hide the notification window when the user clicks on it
hideNotification(context, campaignUrn);
//Broadcast to Ohmage
Intent i = new Intent(ACTION_TRIGGER_NOTIFICATION);
i.putExtra(KEY_CAMPAIGN_URN, campaignUrn);
context.sendBroadcast(i);
}
/*
* Save the visibility of the notification to preferences
*/
private static void saveNotifVisibility(Context context, String campaignUrn, boolean visible) {
SharedPreferences pref = context.getSharedPreferences(
Notifier.class.getName() + "_" + campaignUrn,
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = pref.edit();
editor.putBoolean(KEY_NOTIF_VISIBILITY_PREF, visible);
editor.commit();
TrigPrefManager.registerPreferenceFile(context, campaignUrn, Notifier.class.getName());
}
/*
* Get the current visibility of the notification
*/
private static boolean getNotifVisibility(Context context, String campaignUrn) {
SharedPreferences pref = context.getSharedPreferences(
Notifier.class.getName() + "_" + campaignUrn,
Context.MODE_PRIVATE);
return pref.getBoolean(KEY_NOTIF_VISIBILITY_PREF, false);
}
private static void handleNotifDeleted(Context context, String campaignUrn) {
saveNotifVisibility(context, campaignUrn, false);
}
private static void handleTriggerExpired(Context context, int trigId) {
Log.v(TAG, "Notifier: Handling expiration alarm for: "
+ trigId);
OhmageAnalytics.trigger(context, TriggerStatus.IGNORE, trigId);
//Log information related to expired triggers.
NotifSurveyAdaptor.handleExpiredTrigger(context, trigId);
TriggerDB db = new TriggerDB(context);
db.open();
String campaignUrn = db.getCampaignUrn(trigId);
db.close();
//Quietly refresh the notification
Notifier.refreshNotification(context, campaignUrn, true);
}
/*
* Displays a new trigger notification. If the notification is
* already being displayed, the survey list is updated and the user
* is alerted.
*/
public static void notifyNewTrigger(Context context,
int trigId,
String notifDesc) {
TriggerDB db = new TriggerDB(context);
db.open();
String campaignUrn = db.getCampaignUrn(trigId);
db.close();
//Clear all existing alarms for this trigger if required
cancelAllAlarms(context, trigId);
//Update the notification with quite = false
refreshNotification(context, campaignUrn, false);
NotifDesc desc = new NotifDesc();
if(!desc.loadString(notifDesc)) {
Log.e(TAG, "Notifier: Error parsing notif desc in " +
"notifyNewTrigger()");
return;
}
//Set an alarm to expire this trigger notif
setAlarm(context, ACTION_EXPIRE_ALM, trigId,
desc.getDuration(), null);
//Set an alarm for repeat reminder
int[] repeatDiffs = getRepeatDiffs(desc.getSortedRepeats());
setRepeatAlarm(context, trigId, repeatDiffs);
}
public static void removeTriggerNotification(Context context, int trigId, String campaignUrn) {
//Clear all existing alarms for this trigger if required
cancelAllAlarms(context, trigId);
refreshNotification(context, campaignUrn, true);
}
/* Receiver for all alarms */
public static class NotifReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if(intent.getAction().equals(ACTION_NOTIF_CLICKED)) {
Notifier.handleNotifClicked(context, intent.getStringExtra(KEY_CAMPAIGN_URN));
}
else if(intent.getAction().equals(ACTION_NOTIF_DELETED)) {
Notifier.handleNotifDeleted(context, intent.getStringExtra(KEY_CAMPAIGN_URN));
}
else if(intent.getAction().equals(ACTION_EXPIRE_ALM)) {
Notifier.handleTriggerExpired(context,
intent.getIntExtra(KEY_TRIGGER_ID, -1));
}
else if(intent.getAction().equals(ACTION_REPEAT_ALM)) {
if(!intent.hasExtra(KEY_TRIGGER_ID)) {
return;
}
int trigId = intent.getIntExtra(KEY_TRIGGER_ID, -1);
Notifier.repeatReminder(context, trigId, intent);
}
}
}
}