package org.commcare.services;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.text.format.DateFormat;
import android.util.Log;
import net.sqlcipher.database.SQLiteDatabase;
import org.commcare.AppUtils;
import org.commcare.CommCareApplication;
import org.commcare.activities.DispatchActivity;
import org.commcare.dalvik.R;
import org.commcare.heartbeat.HeartbeatLifecycleManager;
import org.commcare.interfaces.FormSaveCallback;
import org.commcare.logging.AndroidLogger;
import org.commcare.android.database.app.models.UserKeyRecord;
import org.commcare.models.database.user.DatabaseUserOpenHelper;
import org.commcare.models.database.user.UserSandboxUtils;
import org.commcare.models.encryption.CipherPool;
import org.commcare.core.encryption.CryptUtil;
import org.commcare.preferences.CommCarePreferences;
import org.commcare.tasks.DataSubmissionListener;
import org.commcare.tasks.ProcessAndSendTask;
import org.commcare.utils.SessionStateUninitException;
import org.commcare.utils.SessionUnavailableException;
import org.javarosa.core.model.User;
import org.javarosa.core.services.Logger;
import org.javarosa.core.services.locale.Localization;
import org.javarosa.core.util.NoLocalizedTextException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.locks.ReentrantLock;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
/**
* The CommCare Session Service is a persistent service which maintains
* a CommCare login session
*
* @author ctsims
*/
public class CommCareSessionService extends Service {
private NotificationManager mNM;
/**
* Milliseconds to wait before rechecking if the session is still fresh.
*/
private static final long MAINTENANCE_PERIOD = 1000;
/**
* Session length in MS
*/
private static long sessionLength = 1000 * 60 * 60 * 24;
/**
* Lock that must be held to expire the session. Thus if a task holds it,
* the session remains alive. Allows server syncing tasks to prevent the
* session from expiring and closing the user DB while they are running.
*/
public static final ReentrantLock sessionAliveLock = new ReentrantLock();
private Timer maintenanceTimer;
private CipherPool pool;
private byte[] key = null;
private Date sessionExpireDate;
private final Object lock = new Object();
private User user;
private String userKeyRecordUUID;
private int userKeyRecordID;
private SQLiteDatabase userDatabase;
// unique id for logged in notification
private final static int NOTIFICATION = org.commcare.dalvik.R.string.notificationtitle;
private final static int SUBMISSION_NOTIFICATION = org.commcare.dalvik.R.string.submission_notification_title;
// How long to wait until we force the session to finish logging out. Set
// at 90 seconds to make sure huge forms on slow phones actually get saved
private static final long LOGOUT_TIMEOUT = 1000 * 90;
// The logout process start time, used to wrap up logging out if
// the saving of incomplete forms takes too long
private long logoutStartedAt = -1;
// Once key expiration process starts, we want to call this function to
// save the current form if it exists.
private FormSaveCallback formSaver;
private HeartbeatLifecycleManager heartbeatManager;
private boolean heartbeatSucceededForSession;
/**
* Class for clients to access. Because we know this service always
* runs in the same process as its clients, we don't need to deal with
* IPC.
*/
public class LocalBinder extends Binder {
public CommCareSessionService getService() {
return CommCareSessionService.this;
}
}
@Override
public void onCreate() {
mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
setSessionLength();
createCipherPool();
}
@Override
public void onTaskRemoved(Intent rootIntent) {
try {
CommCareApplication.instance().getCurrentSessionWrapper().reset();
} catch (SessionStateUninitException e) {
Log.e(AndroidLogger.SOFT_ASSERT,
"Trying to wipe uninitialized session in session service tear-down");
}
}
public void createCipherPool() {
pool = new CipherPool() {
@Override
public Cipher generateNewCipher() {
synchronized (lock) {
try {
SecretKeySpec spec = new SecretKeySpec(key, "AES");
Cipher decrypter = Cipher.getInstance("AES");
decrypter.init(Cipher.DECRYPT_MODE, spec);
return decrypter;
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
}
}
return null;
}
};
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// We want this service to continue running until it is explicitly
// stopped, so return sticky.
return START_STICKY;
}
@Override
public void onDestroy() {
// Cancel the persistent notification.
this.stopForeground(true);
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
// This is the object that receives interactions from clients. See
// RemoteService for a more complete example.
private final IBinder mBinder = new LocalBinder();
/**
* Show a notification while this service is running.
*/
private void showLoggedInNotification(User user) {
//We always want this click to simply bring the live stack back to the top
Intent callable = new Intent(this, DispatchActivity.class);
callable.setAction("android.intent.action.MAIN");
callable.addCategory("android.intent.category.LAUNCHER");
// The PendingIntent to launch our activity if the user selects this notification
PendingIntent contentIntent = PendingIntent.getActivity(this, 0, callable, 0);
String notificationText;
if (AppUtils.getInstalledAppRecords().size() > 1) {
try {
notificationText = Localization.get("notification.logged.in",
new String[]{Localization.get("app.display.name")});
} catch (NoLocalizedTextException e) {
notificationText = getString(NOTIFICATION);
}
} else {
notificationText = getString(NOTIFICATION);
}
// Set the icon, scrolling text and timestamp
Notification notification = new NotificationCompat.Builder(this)
.setContentTitle(notificationText)
.setContentText("Session Expires: " + DateFormat.format("MMM dd h:mmaa", sessionExpireDate))
.setSmallIcon(org.commcare.dalvik.R.drawable.notification)
.setContentIntent(contentIntent)
.build();
if (user != null) {
//Send the notification.
this.startForeground(NOTIFICATION, notification);
}
}
/**
* Notify the user that they've been timed out and need to relog in
*/
private void showLoggedOutNotification() {
this.stopForeground(true);
Intent i = new Intent(this, DispatchActivity.class);
i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent contentIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
Notification notification = new NotificationCompat.Builder(this)
.setContentTitle(this.getString(R.string.expirenotification))
.setContentText("Click here to log back into your session")
.setSmallIcon(org.commcare.dalvik.R.drawable.notification)
.setContentIntent(contentIntent)
.build();
// Send the notification.
mNM.notify(NOTIFICATION, notification);
}
//Start CommCare Specific Functionality
public SQLiteDatabase getUserDbHandle() {
synchronized (lock) {
return userDatabase;
}
}
/**
* (Re-)open user database
*/
public void prepareStorage(byte[] symetricKey, UserKeyRecord record) {
synchronized (lock) {
this.userKeyRecordUUID = record.getUuid();
this.key = symetricKey;
pool.init();
if (userDatabase != null && userDatabase.isOpen()) {
userDatabase.close();
}
userDatabase = new DatabaseUserOpenHelper(CommCareApplication.instance(), userKeyRecordUUID)
.getWritableDatabase(UserSandboxUtils.getSqlCipherEncodedKey(key));
}
}
/**
* Register a user with a session and start the session expiration timer.
* Assumes user database and key pool have already been setup .
*
* @param user attach this user to the session
*/
public void startSession(User user, UserKeyRecord record) {
synchronized (lock) {
if (user != null) {
Logger.log(AndroidLogger.TYPE_USER, "login|" + user.getUsername() + "|" + user.getUniqueId());
//Let anyone who is listening know!
Intent i = new Intent("org.commcare.dalvik.api.action.session.login");
this.sendBroadcast(i);
}
this.user = user;
this.userKeyRecordID = record.getID();
this.sessionExpireDate = new Date(new Date().getTime() + sessionLength);
if (!CommCareApplication.instance().isConsumerApp()) {
// Put an icon in the status bar for the session, and set up session expiration
// (unless this is a consumer app)
showLoggedInNotification(user);
setUpSessionExpirationTimer();
}
}
}
private void setUpSessionExpirationTimer() {
maintenanceTimer = new Timer("CommCareService");
maintenanceTimer.schedule(new TimerTask() {
@Override
public void run() {
timeToExpireSession();
}
}, MAINTENANCE_PERIOD, MAINTENANCE_PERIOD);
}
/**
* If the session has been alive for longer than its specified duration
* then save any open forms and close it down. If data syncing is in
* progess then don't do anything.
*/
private void timeToExpireSession() {
long currentTime = new Date().getTime();
// If logout process started and has taken longer than the logout
// timeout then wrap-up the process.
if (logoutStartedAt != -1 &&
currentTime > (logoutStartedAt + LOGOUT_TIMEOUT)) {
// Try and grab the logout lock, aborting if synchronization is in
// progress.
if (!CommCareSessionService.sessionAliveLock.tryLock()) {
return;
}
try {
CommCareApplication.instance().expireUserSession();
} finally {
CommCareSessionService.sessionAliveLock.unlock();
}
} else if (isActive() && logoutStartedAt == -1 &&
(currentTime > sessionExpireDate.getTime() ||
(sessionExpireDate.getTime() - currentTime > sessionLength))) {
// If we haven't started closing the session and we're either past
// the session expire time, or the session expires more than its
// period in the future, we need to log the user out. The second
// case occurs if the system's clock is altered.
// Try and grab the logout lock, aborting if synchronization is in
// progress.
if (!CommCareSessionService.sessionAliveLock.tryLock()) {
return;
}
try {
saveFormAndCloseSession();
} finally {
CommCareSessionService.sessionAliveLock.unlock();
}
showLoggedOutNotification();
}
}
/**
* Notify any open form that it needs to save, then close the key session
* after waiting for the form save to complete/timeout.
*/
private void saveFormAndCloseSession() {
// Remember when we started so that if form saving takes too long, the
// maintenance timer will launch CommCareApplication.instance().expireUserSession
logoutStartedAt = new Date().getTime();
// save form progress, if any
synchronized (lock) {
if (formSaver != null) {
formSaver.formSaveCallback();
} else {
CommCareApplication.instance().expireUserSession();
}
}
}
/**
* Allow for the form entry engine to register a method that can be used to
* save any forms being editted when key expiration begins.
*
* @param callbackObj object with a method for saving the current form
* being edited
*/
public void registerFormSaveCallback(FormSaveCallback callbackObj) {
this.formSaver = callbackObj;
}
/**
* Unregister the form save callback; should occur when there is no longer
* a form open that might need to be saved if the session expires.
*/
public void unregisterFormSaveCallback() {
synchronized (lock) {
this.formSaver = null;
}
}
/**
* Closes the key pool and user database.
*/
public void closeServiceResources() {
synchronized (lock) {
if (!isActive()) {
// Since both the FormSaveCallback callback and the maintenance
// timer might call this, only run if it hasn't been called
// before.
return;
}
key = null;
String msg = "Logging out service login";
// Let anyone who is listening know!
Intent i = new Intent("org.commcare.dalvik.api.action.session.logout");
this.sendBroadcast(i);
Logger.log(AndroidLogger.TYPE_MAINTENANCE, msg);
user = null;
if (userDatabase != null) {
if (userDatabase.isOpen()) {
userDatabase.close();
}
userDatabase = null;
}
// timer is null if we aren't actually in the foreground
if (maintenanceTimer != null) {
maintenanceTimer.cancel();
}
logoutStartedAt = -1;
pool.expire();
endHeartbeatLifecycle();
}
}
/**
* Is the session active? Active sessions have an open key pool and user
* database.
*/
public boolean isActive() {
synchronized (lock) {
return (key != null);
}
}
public SecretKey createNewSymmetricKey() {
synchronized (lock) {
// Ensure we have a key to work with
if (!isActive()) {
throw new SessionUnavailableException("Can't generate new key when the user session key is empty.");
}
return CryptUtil.generateSymmetricKey(CryptUtil.uniqueSeedFromSecureStatic(key));
}
}
public String getUserKeyRecordUUID() {
if (key == null) {
// key record hasn't been set, so error out
throw new SessionUnavailableException();
}
return userKeyRecordUUID;
}
public User getLoggedInUser() {
if (user == null) {
throw new SessionUnavailableException();
}
return user;
}
public UserKeyRecord getUserKeyRecord() {
return CommCareApplication.instance().getCurrentApp().getStorage(UserKeyRecord.class)
.read(this.userKeyRecordID);
}
public DataSubmissionListener getListenerForSubmissionNotification() {
return this.getListenerForSubmissionNotification(SUBMISSION_NOTIFICATION);
}
public DataSubmissionListener getListenerForSubmissionNotification(final int notificationId) {
return new DataSubmissionListener() {
int totalItems = -1;
long currentSize = -1;
NotificationCompat.Builder submissionNotification;
int lastUpdate = 0;
@Override
public void beginSubmissionProcess(int totalItems) {
this.totalItems = totalItems;
//We always want this click to simply bring the live stack back to the top
Intent callable = new Intent(CommCareSessionService.this, DispatchActivity.class);
callable.setAction("android.intent.action.MAIN");
callable.addCategory("android.intent.category.LAUNCHER");
// The PendingIntent to launch our activity if the user selects this notification
//TODO: Put something here that will, I dunno, cancel submission or something? Maybe show it live?
PendingIntent contentIntent = PendingIntent.getActivity(CommCareSessionService.this, 0, callable, 0);
submissionNotification = new NotificationCompat.Builder(CommCareSessionService.this)
.setContentTitle(getString(notificationId))
.setContentInfo(getSubmittedFormCount(1, totalItems))
.setContentText("0b transmitted")
.setSmallIcon(org.commcare.dalvik.R.drawable.notification)
.setContentIntent(contentIntent)
.setOngoing(true)
.setWhen(System.currentTimeMillis())
.setTicker(getTickerText(totalItems));
if (user != null) {
mNM.notify(notificationId, submissionNotification.build());
}
}
@Override
public void startSubmission(int itemNumber, long sizeOfItem) {
currentSize = sizeOfItem;
submissionNotification.setContentInfo(getSubmittedFormCount(itemNumber + 1, totalItems));
submissionNotification.setProgress(100, 0, false);
mNM.notify(notificationId, submissionNotification.build());
}
@Override
public void notifyProgress(int itemNumber, long progress) {
int progressPercent = (int)Math.floor((progress * 1.0 / currentSize) * 100);
if (progressPercent - lastUpdate > 5) {
String progressDetails;
if (progress < 1024) {
progressDetails = progress + "b transmitted";
} else if (progress < 1024 * 1024) {
progressDetails = String.format("%1$,.1f", (progress / 1024.0)) + "kb transmitted";
} else {
progressDetails = String.format("%1$,.1f", (progress / (1024.0 * 1024.0))) + "mb transmitted";
}
int pending = ProcessAndSendTask.pending();
if (pending > 1) {
submissionNotification.setContentInfo(pending - 1 + " Pending");
}
submissionNotification.setContentText(progressDetails);
submissionNotification.setProgress(100, progressPercent, false);
mNM.notify(notificationId, submissionNotification.build());
lastUpdate = progressPercent;
}
}
@Override
public void endSubmissionProcess(boolean success) {
mNM.cancel(notificationId);
submissionNotification = null;
totalItems = -1;
currentSize = -1;
lastUpdate = 0;
}
private String getSubmittedFormCount(int current, int total) {
return current + "/" + total;
}
private String getTickerText(int total) {
return "CommCare submitting " + total + " forms";
}
};
}
/**
* Read the login session duration from app preferences and set the session
* length accordingly.
*/
private void setSessionLength() {
sessionLength = CommCarePreferences.getLoginDuration() * 1000;
}
public void setCurrentUser(User user, String password) {
this.user = user;
this.user.setCachedPwd(password);
}
public void initHeartbeatLifecycle() {
if (heartbeatManager == null) {
heartbeatManager = new HeartbeatLifecycleManager(this);
}
heartbeatManager.startHeartbeatCommunications();
}
private void endHeartbeatLifecycle() {
if (heartbeatManager != null) {
heartbeatManager.endHeartbeatCommunications();
heartbeatManager = null;
}
}
public void setHeartbeatSuccess() {
this.heartbeatSucceededForSession = true;
}
public boolean heartbeatSucceededForSession() {
return heartbeatSucceededForSession;
}
}