Initial checkin of AOSP Messaging app.

b/23110861

Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
This commit is contained in:
Mike Dodd
2015-08-12 08:58:28 -07:00
parent 8b3e2b9c1b
commit 461a34b466
1645 changed files with 186271 additions and 0 deletions
@@ -0,0 +1,295 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DataModelException;
import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
import com.android.messaging.datamodel.action.ActionMonitor.ActionExecutedListener;
import com.android.messaging.util.LogUtil;
import java.util.LinkedList;
import java.util.List;
/**
* Base class for operations that perform application business logic off the main UI thread while
* holding a wake lock.
* .
* Note all derived classes need to provide real implementation of Parcelable (this is abstract)
*/
public abstract class Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// Members holding the parameters common to all actions - no action state
public final String actionKey;
// If derived classes keep their data in actionParameters then parcelable is trivial
protected Bundle actionParameters;
// This does not get written to the parcel
private final List<Action> mBackgroundActions = new LinkedList<Action>();
/**
* Process the action locally - runs on action service thread.
* TODO: Currently, there is no way for this method to indicate failure
* @return result to be passed in to {@link ActionExecutedListener#onActionExecuted}. It is
* also the result passed in to {@link ActionCompletedListener#onActionSucceeded} if
* there is no background work.
*/
protected Object executeAction() {
return null;
}
/**
* Queues up background work ie. {@link #doBackgroundWork} will be called on the
* background worker thread.
*/
protected void requestBackgroundWork() {
mBackgroundActions.add(this);
}
/**
* Queues up background actions for background processing after the current action has
* completed its processing ({@link #executeAction}, {@link processBackgroundCompletion}
* or {@link #processBackgroundFailure}) on the Action thread.
* @param backgroundAction
*/
protected void requestBackgroundWork(final Action backgroundAction) {
mBackgroundActions.add(backgroundAction);
}
/**
* Return flag indicating if any actions have been queued
*/
public boolean hasBackgroundActions() {
return !mBackgroundActions.isEmpty();
}
/**
* Send queued actions to the background worker provided
*/
public void sendBackgroundActions(final BackgroundWorker worker) {
worker.queueBackgroundWork(mBackgroundActions);
mBackgroundActions.clear();
}
/**
* Do work in a long running background worker thread.
* {@link #requestBackgroundWork} needs to be called for this method to
* be called. {@link #processBackgroundFailure} will be called on the Action service thread
* if this method throws {@link DataModelException}.
* @return response that is to be passed to {@link #processBackgroundResponse}
*/
protected Bundle doBackgroundWork() throws DataModelException {
return null;
}
/**
* Process the success response from the background worker. Runs on action service thread.
* @param response the response returned by {@link #doBackgroundWork}
* @return result to be passed in to {@link ActionCompletedListener#onActionSucceeded}
*/
protected Object processBackgroundResponse(final Bundle response) {
return null;
}
/**
* Called in case of failures when sending background actions. Runs on action service thread
* @return result to be passed in to {@link ActionCompletedListener#onActionFailed}
*/
protected Object processBackgroundFailure() {
return null;
}
/**
* Constructor
*/
protected Action(final String key) {
this.actionKey = key;
this.actionParameters = new Bundle();
}
/**
* Constructor
*/
protected Action() {
this.actionKey = generateUniqueActionKey(getClass().getSimpleName());
this.actionParameters = new Bundle();
}
/**
* Queue an action and monitor for processing by the ActionService via the factory helper
*/
protected void start(final ActionMonitor monitor) {
ActionMonitor.registerActionMonitor(this.actionKey, monitor);
DataModel.startActionService(this);
}
/**
* Queue an action for processing by the ActionService via the factory helper
*/
public void start() {
DataModel.startActionService(this);
}
/**
* Queue an action for delayed processing by the ActionService via the factory helper
*/
public void schedule(final int requestCode, final long delayMs) {
DataModel.scheduleAction(this, requestCode, delayMs);
}
/**
* Called when action queues ActionService intent
*/
protected final void markStart() {
ActionMonitor.setState(this, ActionMonitor.STATE_CREATED,
ActionMonitor.STATE_QUEUED);
}
/**
* Mark the beginning of local action execution
*/
protected final void markBeginExecute() {
ActionMonitor.setState(this, ActionMonitor.STATE_QUEUED,
ActionMonitor.STATE_EXECUTING);
}
/**
* Mark the end of local action execution - either completes the action or queues
* background actions
*/
protected final void markEndExecute(final Object result) {
final boolean hasBackgroundActions = hasBackgroundActions();
ActionMonitor.setExecutedState(this, ActionMonitor.STATE_EXECUTING,
hasBackgroundActions, result);
if (!hasBackgroundActions) {
ActionMonitor.setCompleteState(this, ActionMonitor.STATE_EXECUTING,
result, true);
}
}
/**
* Update action state to indicate that the background worker is starting
*/
protected final void markBackgroundWorkStarting() {
ActionMonitor.setState(this,
ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED,
ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION);
}
/**
* Update action state to indicate that the background worker has posted its response
* (or failure) to the Action service
*/
protected final void markBackgroundCompletionQueued() {
ActionMonitor.setState(this,
ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION,
ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED);
}
/**
* Update action state to indicate the background action failed but is being re-queued for retry
*/
protected final void markBackgroundWorkQueued() {
ActionMonitor.setState(this,
ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION,
ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED);
}
/**
* Called by ActionService to process a response from the background worker
* @param response the response returned by {@link #doBackgroundWork}
*/
protected final void processBackgroundWorkResponse(final Bundle response) {
ActionMonitor.setState(this,
ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED,
ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE);
final Object result = processBackgroundResponse(response);
ActionMonitor.setCompleteState(this,
ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE, result, true);
}
/**
* Called by ActionService when a background action fails
*/
protected final void processBackgroundWorkFailure() {
final Object result = processBackgroundFailure();
ActionMonitor.setCompleteState(this, ActionMonitor.STATE_UNDEFINED,
result, false);
}
private static final Object sLock = new Object();
private static long sActionIdx = System.currentTimeMillis() * 1000;
/**
* Helper method to generate a unique operation index
*/
protected static long getActionIdx() {
long idx = 0;
synchronized (sLock) {
idx = ++sActionIdx;
}
return idx;
}
/**
* This helper can be used to generate a unique key used to identify an action.
* @param baseKey - key generated to identify the action parameters
* @return - composite key generated by appending unique index
*/
protected static String generateUniqueActionKey(final String baseKey) {
final StringBuilder key = new StringBuilder();
if (!TextUtils.isEmpty(baseKey)) {
key.append(baseKey);
}
key.append(":");
key.append(getActionIdx());
return key.toString();
}
/**
* Most derived classes use this base implementation (unless they include files handles)
*/
@Override
public int describeContents() {
return 0;
}
/**
* Derived classes need to implement writeToParcel (but typically should call this method
* to parcel Action member variables before they parcel their member variables).
*/
public void writeActionToParcel(final Parcel parcel, final int flags) {
parcel.writeString(this.actionKey);
parcel.writeBundle(this.actionParameters);
}
/**
* Helper for derived classes to implement parcelable
*/
public Action(final Parcel in) {
this.actionKey = in.readString();
// Note: Need to set classloader to ensure we can un-parcel classes from this package
this.actionParameters = in.readBundle(Action.class.getClassLoader());
}
}
@@ -0,0 +1,477 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Handler;
import android.support.v4.util.SimpleArrayMap;
import android.text.TextUtils;
import com.android.messaging.util.Assert.RunsOnAnyThread;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.ThreadUtil;
import com.google.common.annotations.VisibleForTesting;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;
/**
* Base class for action monitors
* Actions come in various flavors but
* o) Fire and forget - no monitor
* o) Immediate local processing only - will trigger ActionCompletedListener when done
* o) Background worker processing only - will trigger ActionCompletedListener when done
* o) Immediate local processing followed by background work followed by more local processing
* - will trigger ActionExecutedListener once local processing complete and
* ActionCompletedListener when second set of local process (dealing with background
* worker response) is complete
*/
public class ActionMonitor {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Interface used to notify on completion of local execution for an action
*/
public interface ActionExecutedListener {
/**
* @param result value returned by {@link Action#executeAction}
*/
@RunsOnMainThread
abstract void onActionExecuted(ActionMonitor monitor, final Action action,
final Object data, final Object result);
}
/**
* Interface used to notify action completion
*/
public interface ActionCompletedListener {
/**
* @param result object returned from processing the action. This is the value returned by
* {@link Action#executeAction} if there is no background work, or
* else the value returned by
* {@link Action#processBackgroundResponse}
*/
@RunsOnMainThread
abstract void onActionSucceeded(ActionMonitor monitor,
final Action action, final Object data, final Object result);
/**
* @param result value returned by {@link Action#processBackgroundFailure}
*/
@RunsOnMainThread
abstract void onActionFailed(ActionMonitor monitor, final Action action,
final Object data, final Object result);
}
/**
* Interface for being notified of action state changes - used for profiling, testing only
*/
protected interface ActionStateChangedListener {
/**
* @param action the action that is changing state
* @param state the new state of the action
*/
@RunsOnAnyThread
void onActionStateChanged(Action action, int state);
}
/**
* Operations always start out as STATE_CREATED and finish as STATE_COMPLETE.
* Some common state transition sequences in between include:
* <ul>
* <li>Local data change only : STATE_QUEUED - STATE_EXECUTING
* <li>Background worker request only : STATE_BACKGROUND_ACTIONS_QUEUED
* - STATE_EXECUTING_BACKGROUND_ACTION
* - STATE_BACKGROUND_COMPLETION_QUEUED
* - STATE_PROCESSING_BACKGROUND_RESPONSE
* <li>Local plus background worker request : STATE_QUEUED - STATE_EXECUTING
* - STATE_BACKGROUND_ACTIONS_QUEUED
* - STATE_EXECUTING_BACKGROUND_ACTION
* - STATE_BACKGROUND_COMPLETION_QUEUED
* - STATE_PROCESSING_BACKGROUND_RESPONSE
* </ul>
*/
protected static final int STATE_UNDEFINED = 0;
protected static final int STATE_CREATED = 1; // Just created
protected static final int STATE_QUEUED = 2; // Action queued for processing
protected static final int STATE_EXECUTING = 3; // Action processing on datamodel thread
protected static final int STATE_BACKGROUND_ACTIONS_QUEUED = 4;
protected static final int STATE_EXECUTING_BACKGROUND_ACTION = 5;
// The background work has completed, either returning a success response or resulting in a
// failure
protected static final int STATE_BACKGROUND_COMPLETION_QUEUED = 6;
protected static final int STATE_PROCESSING_BACKGROUND_RESPONSE = 7;
protected static final int STATE_COMPLETE = 8; // Action complete
/**
* Lock used to protect access to state and listeners
*/
private final Object mLock = new Object();
/**
* Current state of action
*/
@VisibleForTesting
protected int mState;
/**
* Listener which is notified on action completion
*/
private ActionCompletedListener mCompletedListener;
/**
* Listener which is notified on action executed
*/
private ActionExecutedListener mExecutedListener;
/**
* Listener which is notified of state changes
*/
private ActionStateChangedListener mStateChangedListener;
/**
* Handler used to post results back to caller
*/
private final Handler mHandler;
/**
* Data passed back to listeners (associated with the action when it is created)
*/
private final Object mData;
/**
* The action key is used to determine equivalence of operations and their requests
*/
private final String mActionKey;
/**
* Get action key identifying associated action
*/
public String getActionKey() {
return mActionKey;
}
/**
* Unregister listeners so that they will not be called back - override this method if needed
*/
public void unregister() {
clearListeners();
}
/**
* Unregister listeners so that they will not be called
*/
protected final void clearListeners() {
synchronized (mLock) {
mCompletedListener = null;
mExecutedListener = null;
}
}
/**
* Create a monitor associated with a particular action instance
*/
protected ActionMonitor(final int initialState, final String actionKey,
final Object data) {
mHandler = ThreadUtil.getMainThreadHandler();
mActionKey = actionKey;
mState = initialState;
mData = data;
}
/**
* Return flag to indicate if action is complete
*/
public boolean isComplete() {
boolean complete = false;
synchronized (mLock) {
complete = (mState == STATE_COMPLETE);
}
return complete;
}
/**
* Set listener that will be called with action completed result
*/
protected final void setCompletedListener(final ActionCompletedListener listener) {
synchronized (mLock) {
mCompletedListener = listener;
}
}
/**
* Set listener that will be called with local execution result
*/
protected final void setExecutedListener(final ActionExecutedListener listener) {
synchronized (mLock) {
mExecutedListener = listener;
}
}
/**
* Set listener that will be called with local execution result
*/
protected final void setStateChangedListener(final ActionStateChangedListener listener) {
synchronized (mLock) {
mStateChangedListener = listener;
}
}
/**
* Perform a state update transition
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param newState - new state which will be set
*/
@VisibleForTesting
protected void updateState(final Action action, final int expectedOldState,
final int newState) {
ActionStateChangedListener listener = null;
synchronized (mLock) {
if (expectedOldState != STATE_UNDEFINED &&
mState != expectedOldState) {
throw new IllegalStateException("On updateState to " + newState + " was " + mState
+ " expecting " + expectedOldState);
}
if (newState != mState) {
mState = newState;
listener = mStateChangedListener;
}
}
if (listener != null) {
listener.onActionStateChanged(action, newState);
}
}
/**
* Perform a state update transition
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param newState - new state which will be set
*/
static void setState(final Action action, final int expectedOldState,
final int newState) {
int oldMonitorState = expectedOldState;
int newMonitorState = newState;
final ActionMonitor monitor
= ActionMonitor.lookupActionMonitor(action.actionKey);
if (monitor != null) {
oldMonitorState = monitor.mState;
monitor.updateState(action, expectedOldState, newState);
newMonitorState = monitor.mState;
}
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
df.setTimeZone(TimeZone.getTimeZone("UTC"));
LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ "UTC State = " + oldMonitorState + " - " + newMonitorState);
}
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param result - object returned from processing the action. This is the value returned by
* {@link Action#executeAction} if there is no background work, or
* else the value returned by {@link Action#processBackgroundResponse}
* or {@link Action#processBackgroundFailure}
*/
private final void complete(final Action action,
final int expectedOldState, final Object result,
final boolean succeeded) {
ActionCompletedListener completedListener = null;
synchronized (mLock) {
setState(action, expectedOldState, STATE_COMPLETE);
completedListener = mCompletedListener;
mExecutedListener = null;
mStateChangedListener = null;
}
if (completedListener != null) {
// Marshal to UI thread
mHandler.post(new Runnable() {
@Override
public void run() {
ActionCompletedListener listener = null;
synchronized (mLock) {
if (mCompletedListener != null) {
listener = mCompletedListener;
}
mCompletedListener = null;
}
if (listener != null) {
if (succeeded) {
listener.onActionSucceeded(ActionMonitor.this,
action, mData, result);
} else {
listener.onActionFailed(ActionMonitor.this,
action, mData, result);
}
}
}
});
}
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param result - object returned from processing the action. This is the value returned by
* {@link Action#executeAction} if there is no background work, or
* else the value returned by {@link Action#processBackgroundResponse}
* or {@link Action#processBackgroundFailure}
*/
static void setCompleteState(final Action action, final int expectedOldState,
final Object result, final boolean succeeded) {
int oldMonitorState = expectedOldState;
final ActionMonitor monitor
= ActionMonitor.lookupActionMonitor(action.actionKey);
if (monitor != null) {
oldMonitorState = monitor.mState;
monitor.complete(action, expectedOldState, result, succeeded);
unregisterActionMonitorIfComplete(action.actionKey, monitor);
}
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
df.setTimeZone(TimeZone.getTimeZone("UTC"));
LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ "UTC State = " + oldMonitorState + " - " + STATE_COMPLETE);
}
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param hasBackgroundActions - has the completing action requested background work
* @param result - the return value of {@link Action#executeAction}
*/
final void executed(final Action action,
final int expectedOldState, final boolean hasBackgroundActions, final Object result) {
ActionExecutedListener executedListener = null;
synchronized (mLock) {
if (hasBackgroundActions) {
setState(action, expectedOldState, STATE_BACKGROUND_ACTIONS_QUEUED);
}
executedListener = mExecutedListener;
}
if (executedListener != null) {
// Marshal to UI thread
mHandler.post(new Runnable() {
@Override
public void run() {
ActionExecutedListener listener = null;
synchronized (mLock) {
if (mExecutedListener != null) {
listener = mExecutedListener;
mExecutedListener = null;
}
}
if (listener != null) {
listener.onActionExecuted(ActionMonitor.this,
action, mData, result);
}
}
});
}
}
/**
* Mark action complete
* @param action - action whose state is updating
* @param expectedOldState - expected existing state of action (can be UNKNOWN)
* @param hasBackgroundActions - has the completing action requested background work
* @param result - the return value of {@link Action#executeAction}
*/
static void setExecutedState(final Action action,
final int expectedOldState, final boolean hasBackgroundActions, final Object result) {
int oldMonitorState = expectedOldState;
final ActionMonitor monitor
= ActionMonitor.lookupActionMonitor(action.actionKey);
if (monitor != null) {
oldMonitorState = monitor.mState;
monitor.executed(action, expectedOldState, hasBackgroundActions, result);
}
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
df.setTimeZone(TimeZone.getTimeZone("UTC"));
LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date())
+ "UTC State = " + oldMonitorState + " - EXECUTED");
}
}
/**
* Map of action monitors indexed by actionKey
*/
@VisibleForTesting
static SimpleArrayMap<String, ActionMonitor> sActionMonitors =
new SimpleArrayMap<String, ActionMonitor>();
/**
* Insert new monitor into map
*/
static void registerActionMonitor(final String actionKey,
final ActionMonitor monitor) {
if (monitor != null
&& (TextUtils.isEmpty(monitor.getActionKey())
|| TextUtils.isEmpty(actionKey)
|| !actionKey.equals(monitor.getActionKey()))) {
throw new IllegalArgumentException("Monitor key " + monitor.getActionKey()
+ " not compatible with action key " + actionKey);
}
synchronized (sActionMonitors) {
sActionMonitors.put(actionKey, monitor);
}
}
/**
* Find monitor associated with particular action
*/
private static ActionMonitor lookupActionMonitor(final String actionKey) {
ActionMonitor monitor = null;
synchronized (sActionMonitors) {
monitor = sActionMonitors.get(actionKey);
}
return monitor;
}
/**
* Remove monitor from map
*/
@VisibleForTesting
static void unregisterActionMonitor(final String actionKey,
final ActionMonitor monitor) {
if (monitor != null) {
synchronized (sActionMonitors) {
sActionMonitors.remove(actionKey);
}
}
}
/**
* Remove monitor from map if the action is complete
*/
static void unregisterActionMonitorIfComplete(final String actionKey,
final ActionMonitor monitor) {
if (monitor != null && monitor.isComplete()) {
synchronized (sActionMonitors) {
sActionMonitors.remove(actionKey);
}
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.app.PendingIntent;
import android.content.Context;
import android.os.Bundle;
/**
* Class providing interface for the ActionService - can be stubbed for testing
*/
public class ActionService {
protected static PendingIntent makeStartActionPendingIntent(final Context context,
final Action action, final int requestCode, final boolean launchesAnActivity) {
return ActionServiceImpl.makeStartActionPendingIntent(context, action, requestCode,
launchesAnActivity);
}
/**
* Start an action by posting it over the the ActionService
*/
public void startAction(final Action action) {
ActionServiceImpl.startAction(action);
}
/**
* Schedule a delayed action by posting it over the the ActionService
*/
public void scheduleAction(final Action action, final int code,
final long delayMs) {
ActionServiceImpl.scheduleAction(action, code, delayMs);
}
/**
* Process a response from the BackgroundWorker in the ActionService
*/
protected void handleResponseFromBackgroundWorker(
final Action action, final Bundle response) {
ActionServiceImpl.handleResponseFromBackgroundWorker(action, response);
}
/**
* Process a failure from the BackgroundWorker in the ActionService
*/
protected void handleFailureFromBackgroundWorker(final Action action,
final Exception exception) {
ActionServiceImpl.handleFailureFromBackgroundWorker(action, exception);
}
}
@@ -0,0 +1,341 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.app.AlarmManager;
import android.app.IntentService;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.LoggingTimer;
import com.android.messaging.util.WakeLockHelper;
import com.google.common.annotations.VisibleForTesting;
/**
* ActionService used to perform background processing for data model
*/
public class ActionServiceImpl extends IntentService {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final boolean VERBOSE = false;
public ActionServiceImpl() {
super("ActionService");
}
/**
* Start action by sending intent to the service
* @param action - action to start
*/
protected static void startAction(final Action action) {
final Intent intent = makeIntent(OP_START_ACTION);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
action.markStart();
startServiceWithIntent(intent);
}
/**
* Schedule an action to run after specified delay using alarm manager to send pendingintent
* @param action - action to start
* @param requestCode - request code used to collapse requests
* @param delayMs - delay in ms (from now) before action will start
*/
protected static void scheduleAction(final Action action, final int requestCode,
final long delayMs) {
final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
PendingActionReceiver.scheduleAlarm(intent, requestCode, delayMs);
}
/**
* Handle response returned by BackgroundWorker
* @param request - request generating response
* @param response - response from service
*/
protected static void handleResponseFromBackgroundWorker(final Action action,
final Bundle response) {
final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_RESPONSE);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
intent.putExtra(EXTRA_WORKER_RESPONSE, response);
startServiceWithIntent(intent);
}
/**
* Handle response returned by BackgroundWorker
* @param request - request generating failure
*/
protected static void handleFailureFromBackgroundWorker(final Action action,
final Exception exception) {
final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_FAILURE);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
intent.putExtra(EXTRA_WORKER_EXCEPTION, exception);
startServiceWithIntent(intent);
}
// ops
@VisibleForTesting
protected static final int OP_START_ACTION = 200;
@VisibleForTesting
protected static final int OP_RECEIVE_BACKGROUND_RESPONSE = 201;
@VisibleForTesting
protected static final int OP_RECEIVE_BACKGROUND_FAILURE = 202;
// extras
@VisibleForTesting
protected static final String EXTRA_OP_CODE = "op";
@VisibleForTesting
protected static final String EXTRA_ACTION_BUNDLE = "datamodel_action_bundle";
@VisibleForTesting
protected static final String EXTRA_WORKER_EXCEPTION = "worker_exception";
@VisibleForTesting
protected static final String EXTRA_WORKER_RESPONSE = "worker_response";
@VisibleForTesting
protected static final String EXTRA_WORKER_UPDATE = "worker_update";
@VisibleForTesting
protected static final String BUNDLE_ACTION = "bundle_action";
private BackgroundWorker mBackgroundWorker;
/**
* Allocate an intent with a specific opcode.
*/
private static Intent makeIntent(final int opcode) {
final Intent intent = new Intent(Factory.get().getApplicationContext(),
ActionServiceImpl.class);
intent.putExtra(EXTRA_OP_CODE, opcode);
return intent;
}
/**
* Broadcast receiver for alarms scheduled through ActionService.
*/
public static class PendingActionReceiver extends BroadcastReceiver {
static final String ACTION = "com.android.messaging.datamodel.PENDING_ACTION";
/**
* Allocate an intent with a specific opcode and alarm action.
*/
public static Intent makeIntent(final int opcode) {
final Intent intent = new Intent(Factory.get().getApplicationContext(),
PendingActionReceiver.class);
intent.setAction(ACTION);
intent.putExtra(EXTRA_OP_CODE, opcode);
return intent;
}
public static void scheduleAlarm(final Intent intent, final int requestCode,
final long delayMs) {
final Context context = Factory.get().getApplicationContext();
final PendingIntent pendingIntent = PendingIntent.getBroadcast(
context, requestCode, intent, PendingIntent.FLAG_CANCEL_CURRENT);
final AlarmManager mgr =
(AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (delayMs < Long.MAX_VALUE) {
mgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + delayMs, pendingIntent);
} else {
mgr.cancel(pendingIntent);
}
}
/**
* {@inheritDoc}
*/
@Override
public void onReceive(final Context context, final Intent intent) {
ActionServiceImpl.startServiceWithIntent(intent);
}
}
/**
* Creates a pending intent that will trigger a data model action when the intent is
* triggered
*/
public static PendingIntent makeStartActionPendingIntent(final Context context,
final Action action, final int requestCode, final boolean launchesAnActivity) {
final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION);
final Bundle actionBundle = new Bundle();
actionBundle.putParcelable(BUNDLE_ACTION, action);
intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle);
if (launchesAnActivity) {
intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
}
return PendingIntent.getBroadcast(context, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
/**
* {@inheritDoc}
*/
@Override
public void onCreate() {
super.onCreate();
mBackgroundWorker = DataModel.get().getBackgroundWorkerForActionService();
DataModel.get().getConnectivityUtil().registerForSignalStrength();
}
@Override
public void onDestroy() {
super.onDestroy();
DataModel.get().getConnectivityUtil().unregisterForSignalStrength();
}
private static final String WAKELOCK_ID = "bugle_datamodel_service_wakelock";
@VisibleForTesting
static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID);
/**
* Queue intent to the ActionService after acquiring wake lock
*/
private static void startServiceWithIntent(final Intent intent) {
final Context context = Factory.get().getApplicationContext();
final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
// Increase refCount on wake lock - acquiring if necessary
if (VERBOSE) {
LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode);
}
sWakeLock.acquire(context, intent, opcode);
intent.setClass(context, ActionServiceImpl.class);
// TODO: Note that intent will be quietly discarded if it exceeds available rpc
// memory (in total around 1MB). See this article for background
// http://developer.android.com/reference/android/os/TransactionTooLargeException.html
// Perhaps we should keep large structures in the action monitor?
if (context.startService(intent) == null) {
LogUtil.e(TAG,
"ActionService.startServiceWithIntent: failed to start service for intent "
+ intent);
sWakeLock.release(intent, opcode);
}
}
/**
* {@inheritDoc}
*/
@Override
protected void onHandleIntent(final Intent intent) {
if (intent == null) {
// Shouldn't happen but sometimes does following another crash.
LogUtil.w(TAG, "ActionService.onHandleIntent: Called with null intent");
return;
}
final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
sWakeLock.ensure(intent, opcode);
try {
Action action;
final Bundle actionBundle = intent.getBundleExtra(EXTRA_ACTION_BUNDLE);
actionBundle.setClassLoader(getClassLoader());
switch(opcode) {
case OP_START_ACTION: {
action = (Action) actionBundle.getParcelable(BUNDLE_ACTION);
executeAction(action);
break;
}
case OP_RECEIVE_BACKGROUND_RESPONSE: {
action = (Action) actionBundle.getParcelable(BUNDLE_ACTION);
final Bundle response = intent.getBundleExtra(EXTRA_WORKER_RESPONSE);
processBackgroundResponse(action, response);
break;
}
case OP_RECEIVE_BACKGROUND_FAILURE: {
action = (Action) actionBundle.getParcelable(BUNDLE_ACTION);
processBackgroundFailure(action);
break;
}
default:
throw new RuntimeException("Unrecognized opcode in ActionServiceImpl");
}
action.sendBackgroundActions(mBackgroundWorker);
} finally {
// Decrease refCount on wake lock - releasing if necessary
sWakeLock.release(intent, opcode);
}
}
private static final long EXECUTION_TIME_WARN_LIMIT_MS = 1000; // 1 second
/**
* Local execution of action on ActionService thread
*/
private void executeAction(final Action action) {
action.markBeginExecute();
final LoggingTimer timer = createLoggingTimer(action, "#executeAction");
timer.start();
final Object result = action.executeAction();
timer.stopAndLog();
action.markEndExecute(result);
}
/**
* Process response on ActionService thread
*/
private void processBackgroundResponse(final Action action, final Bundle response) {
final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundResponse");
timer.start();
action.processBackgroundWorkResponse(response);
timer.stopAndLog();
}
/**
* Process failure on ActionService thread
*/
private void processBackgroundFailure(final Action action) {
final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundFailure");
timer.start();
action.processBackgroundWorkFailure();
timer.stopAndLog();
}
private static LoggingTimer createLoggingTimer(
final Action action, final String methodName) {
return new LoggingTimer(TAG, action.getClass().getSimpleName() + methodName,
EXECUTION_TIME_WARN_LIMIT_MS);
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import java.util.List;
/**
* Interface between action service and its workers
*/
public class BackgroundWorker {
/**
* Send list of requests from action service to a worker
*/
public void queueBackgroundWork(final List<Action> backgroundActions) {
BackgroundWorkerService.queueBackgroundWork(backgroundActions);
}
}
@@ -0,0 +1,168 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DataModelException;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.LoggingTimer;
import com.android.messaging.util.WakeLockHelper;
import com.google.common.annotations.VisibleForTesting;
import java.util.List;
/**
* Background worker service is an initial example of a background work queue handler
* Used to actually "send" messages which may take some time and should not block ActionService
* or UI
*/
public class BackgroundWorkerService extends IntentService {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final boolean VERBOSE = false;
private static final String WAKELOCK_ID = "bugle_background_worker_wakelock";
@VisibleForTesting
static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID);
private final ActionService mHost;
public BackgroundWorkerService() {
super("BackgroundWorker");
mHost = DataModel.get().getActionService();
}
/**
* Queue a list of requests from action service to this worker
*/
public static void queueBackgroundWork(final List<Action> actions) {
for (final Action action : actions) {
startServiceWithAction(action, 0);
}
}
// ops
@VisibleForTesting
protected static final int OP_PROCESS_REQUEST = 400;
// extras
@VisibleForTesting
protected static final String EXTRA_OP_CODE = "op";
@VisibleForTesting
protected static final String EXTRA_ACTION = "action";
@VisibleForTesting
protected static final String EXTRA_ATTEMPT = "retry_attempt";
/**
* Queue action intent to the BackgroundWorkerService after acquiring wake lock
*/
private static void startServiceWithAction(final Action action,
final int retryCount) {
final Intent intent = new Intent();
intent.putExtra(EXTRA_ACTION, action);
intent.putExtra(EXTRA_ATTEMPT, retryCount);
startServiceWithIntent(OP_PROCESS_REQUEST, intent);
}
/**
* Queue intent to the BackgroundWorkerService after acquiring wake lock
*/
private static void startServiceWithIntent(final int opcode, final Intent intent) {
final Context context = Factory.get().getApplicationContext();
intent.setClass(context, BackgroundWorkerService.class);
intent.putExtra(EXTRA_OP_CODE, opcode);
sWakeLock.acquire(context, intent, opcode);
if (VERBOSE) {
LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode);
}
if (context.startService(intent) == null) {
LogUtil.e(TAG,
"BackgroundWorkerService.startServiceWithAction: failed to start service for "
+ opcode);
sWakeLock.release(intent, opcode);
}
}
@Override
protected void onHandleIntent(final Intent intent) {
if (intent == null) {
// Shouldn't happen but sometimes does following another crash.
LogUtil.w(TAG, "BackgroundWorkerService.onHandleIntent: Called with null intent");
return;
}
final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0);
sWakeLock.ensure(intent, opcode);
try {
switch(opcode) {
case OP_PROCESS_REQUEST: {
final Action action = intent.getParcelableExtra(EXTRA_ACTION);
final int attempt = intent.getIntExtra(EXTRA_ATTEMPT, -1);
doBackgroundWork(action, attempt);
break;
}
default:
throw new RuntimeException("Unrecognized opcode in BackgroundWorkerService");
}
} finally {
sWakeLock.release(intent, opcode);
}
}
/**
* Local execution of background work for action on ActionService thread
*/
private void doBackgroundWork(final Action action, final int attempt) {
action.markBackgroundWorkStarting();
Bundle response = null;
try {
final LoggingTimer timer = new LoggingTimer(
TAG, action.getClass().getSimpleName() + "#doBackgroundWork");
timer.start();
response = action.doBackgroundWork();
timer.stopAndLog();
action.markBackgroundCompletionQueued();
mHost.handleResponseFromBackgroundWorker(action, response);
} catch (final Exception exception) {
final boolean retry = false;
LogUtil.e(TAG, "Error in background worker", exception);
if (!(exception instanceof DataModelException)) {
// DataModelException is expected (sort-of) and handled in handleFailureFromWorker
// below, but other exceptions should crash ENG builds
Assert.fail("Unexpected error in background worker - abort");
}
if (retry) {
action.markBackgroundWorkQueued();
startServiceWithAction(action, attempt + 1);
} else {
action.markBackgroundCompletionQueued();
mHost.handleFailureFromBackgroundWorker(action, exception);
}
}
}
}
@@ -0,0 +1,172 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.content.res.Resources;
import android.widget.Toast;
import com.android.messaging.Factory;
import com.android.messaging.R;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.AccessibilityUtil;
import com.android.messaging.util.PhoneUtils;
import com.android.messaging.util.ThreadUtil;
import javax.annotation.Nullable;
/**
* Shows one-time, transient notifications in response to action failures (i.e. permanent failures
* when sending a message) by showing toasts.
*/
public class BugleActionToasts {
/**
* Called when SendMessageAction or DownloadMmsAction finishes
* @param conversationId the conversation of the sent or downloaded message
* @param success did the action succeed
* @param status the message sending status
* @param isSms whether the message is sent using SMS
* @param subId the subId of the SIM related to this send
* @param isSend whether it is a send (false for download)
*/
static void onSendMessageOrManualDownloadActionCompleted(
final String conversationId,
final boolean success,
final int status,
final boolean isSms,
final int subId,
final boolean isSend) {
// We only show notifications for two cases, i.e. when mobile data is off or when we are
// in airplane mode, both of which fail fast with permanent failures.
if (!success && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
final PhoneUtils phoneUtils = PhoneUtils.get(subId);
if (phoneUtils.isAirplaneModeOn()) {
if (isSend) {
showToast(R.string.send_message_failure_airplane_mode);
} else {
showToast(R.string.download_message_failure_airplane_mode);
}
return;
} else if (!isSms && !phoneUtils.isMobileDataEnabled()) {
if (isSend) {
showToast(R.string.send_message_failure_no_data);
} else {
showToast(R.string.download_message_failure_no_data);
}
return;
}
}
if (AccessibilityUtil.isTouchExplorationEnabled(Factory.get().getApplicationContext())) {
final boolean isFocusedConversation = DataModel.get().isFocusedConversation(conversationId);
if (isFocusedConversation && success) {
// Using View.announceForAccessibility may be preferable, but we do not have a
// View, and so we use a toast instead.
showToast(isSend ? R.string.send_message_success
: R.string.download_message_success);
return;
}
// {@link MessageNotificationState#checkFailedMessages} does not post a notification for
// failures in observable conversations. For accessibility, we provide an indication
// here.
final boolean isObservableConversation = DataModel.get().isNewMessageObservable(
conversationId);
if (isObservableConversation && !success) {
showToast(isSend ? R.string.send_message_failure
: R.string.download_message_failure);
}
}
}
public static void onMessageReceived(final String conversationId,
@Nullable final ParticipantData sender, @Nullable final MessageData message) {
final Context context = Factory.get().getApplicationContext();
if (AccessibilityUtil.isTouchExplorationEnabled(context)) {
final boolean isFocusedConversation = DataModel.get().isFocusedConversation(
conversationId);
if (isFocusedConversation) {
final Resources res = context.getResources();
final String senderDisplayName = (sender == null)
? res.getString(R.string.unknown_sender) : sender.getDisplayName(false);
final String announcement = res.getString(
R.string.incoming_message_announcement, senderDisplayName,
(message == null) ? "" : message.getMessageText());
showToast(announcement);
}
}
}
public static void onConversationDeleted() {
showToast(R.string.conversation_deleted);
}
private static void showToast(final int messageResId) {
ThreadUtil.getMainThreadHandler().post(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(),
getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show();
}
});
}
private static void showToast(final String message) {
ThreadUtil.getMainThreadHandler().post(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show();
}
});
}
private static Context getApplicationContext() {
return Factory.get().getApplicationContext();
}
private static class UpdateDestinationBlockedActionToast
implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener {
private final Context mContext;
UpdateDestinationBlockedActionToast(final Context context) {
mContext = context;
}
@Override
public void onUpdateDestinationBlockedAction(
final UpdateDestinationBlockedAction action,
final boolean success,
final boolean block,
final String destination) {
if (success) {
Toast.makeText(mContext,
block
? R.string.update_destination_blocked
: R.string.update_destination_unblocked,
Toast.LENGTH_LONG
).show();
}
}
}
public static UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener
makeUpdateDestinationBlockedActionListener(final Context context) {
return new UpdateDestinationBlockedActionToast(context);
}
}
@@ -0,0 +1,205 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DataModelException;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.widget.WidgetConversationProvider;
import java.util.ArrayList;
import java.util.List;
/**
* Action used to delete a conversation.
*/
public class DeleteConversationAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void deleteConversation(final String conversationId, final long cutoffTimestamp) {
final DeleteConversationAction action = new DeleteConversationAction(conversationId,
cutoffTimestamp);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_CUTOFF_TIMESTAMP = "cutoff_timestamp";
private DeleteConversationAction(final String conversationId, final long cutoffTimestamp) {
super();
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
// TODO: Should we set cuttoff timestamp to prevent us deleting new messages?
actionParameters.putLong(KEY_CUTOFF_TIMESTAMP, cutoffTimestamp);
}
// Delete conversation from both the local DB and telephony in the background so sync cannot
// run concurrently and incorrectly try to recreate the conversation's messages locally. The
// telephony database can sometimes be quite slow to delete conversations, so we delete from
// the local DB first, notify the UI, and then delete from telephony.
@Override
protected Bundle doBackgroundWork() throws DataModelException {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final long cutoffTimestamp = actionParameters.getLong(KEY_CUTOFF_TIMESTAMP);
if (!TextUtils.isEmpty(conversationId)) {
// First find the thread id for this conversation.
final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
if (BugleDatabaseOperations.deleteConversation(db, conversationId, cutoffTimestamp)) {
LogUtil.i(TAG, "DeleteConversationAction: Deleted local conversation "
+ conversationId);
BugleActionToasts.onConversationDeleted();
// Remove notifications if necessary
BugleNotifications.update(true /* silent */, null /* conversationId */,
BugleNotifications.UPDATE_MESSAGES);
// We have changed the conversation list
MessagingContentProvider.notifyConversationListChanged();
// Notify the widget the conversation is deleted so it can go into its configure state.
WidgetConversationProvider.notifyConversationDeleted(
Factory.get().getApplicationContext(),
conversationId);
} else {
LogUtil.w(TAG, "DeleteConversationAction: Could not delete local conversation "
+ conversationId);
return null;
}
// Now delete from telephony DB. MmsSmsProvider throws an exception if the thread id is
// less than 0. If it's greater than zero, it will delete all messages with that thread
// id, even if there's no corresponding row in the threads table.
if (threadId >= 0) {
final int count = MmsUtils.deleteThread(threadId, cutoffTimestamp);
if (count > 0) {
LogUtil.i(TAG, "DeleteConversationAction: Deleted telephony thread "
+ threadId + " (cutoffTimestamp = " + cutoffTimestamp + ")");
} else {
LogUtil.w(TAG, "DeleteConversationAction: Could not delete thread from "
+ "telephony: conversationId = " + conversationId + ", thread id = "
+ threadId);
}
} else {
LogUtil.w(TAG, "DeleteConversationAction: Local conversation " + conversationId
+ " has an invalid telephony thread id; will delete messages individually");
deleteConversationMessagesFromTelephony();
}
} else {
LogUtil.e(TAG, "DeleteConversationAction: conversationId is empty");
}
return null;
}
/**
* Deletes all the telephony messages for the local conversation being deleted.
* <p>
* This is a fallback used when the conversation is not associated with any telephony thread,
* or its thread id is invalid (e.g. negative). This is not common, but can happen sometimes
* (e.g. the Unknown Sender conversation). In the usual case of deleting a conversation, we
* don't need this because the telephony provider automatically deletes messages when a thread
* is deleted.
*/
private void deleteConversationMessagesFromTelephony() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
Assert.notNull(conversationId);
final List<Uri> messageUris = new ArrayList<>();
Cursor cursor = null;
try {
cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
new String[] { MessageColumns.SMS_MESSAGE_URI },
MessageColumns.CONVERSATION_ID + "=?",
new String[] { conversationId },
null, null, null);
while (cursor.moveToNext()) {
String messageUri = cursor.getString(0);
try {
messageUris.add(Uri.parse(messageUri));
} catch (Exception e) {
LogUtil.e(TAG, "DeleteConversationAction: Could not parse message uri "
+ messageUri);
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
for (Uri messageUri : messageUris) {
int count = MmsUtils.deleteMessage(messageUri);
if (count > 0) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "DeleteConversationAction: Deleted telephony message "
+ messageUri);
}
} else {
LogUtil.w(TAG, "DeleteConversationAction: Could not delete telephony message "
+ messageUri);
}
}
}
@Override
protected Object executeAction() {
requestBackgroundWork();
return null;
}
private DeleteConversationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DeleteConversationAction> CREATOR
= new Parcelable.Creator<DeleteConversationAction>() {
@Override
public DeleteConversationAction createFromParcel(final Parcel in) {
return new DeleteConversationAction(in);
}
@Override
public DeleteConversationAction[] newArray(final int size) {
return new DeleteConversationAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.LogUtil;
/**
* Action used to delete a single message.
*/
public class DeleteMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void deleteMessage(final String messageId) {
final DeleteMessageAction action = new DeleteMessageAction(messageId);
action.start();
}
private static final String KEY_MESSAGE_ID = "message_id";
private DeleteMessageAction(final String messageId) {
super();
actionParameters.putString(KEY_MESSAGE_ID, messageId);
}
// Doing this work in the background so that we're not competing with sync
// which could bring the deleted message back to life between the time we deleted
// it locally and deleted it in telephony (sync is also done on doBackgroundWork).
//
// Previously this block of code deleted from telephony first but that can be very
// slow (on the order of seconds) so this was modified to first delete locally, trigger
// the UI update, then delete from telephony.
@Override
protected Bundle doBackgroundWork() {
final DatabaseWrapper db = DataModel.get().getDatabase();
// First find the thread id for this conversation.
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
if (!TextUtils.isEmpty(messageId)) {
// Check message still exists
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
if (message != null) {
// Delete from local DB
int count = BugleDatabaseOperations.deleteMessage(db, messageId);
if (count > 0) {
LogUtil.i(TAG, "DeleteMessageAction: Deleted local message "
+ messageId);
} else {
LogUtil.w(TAG, "DeleteMessageAction: Could not delete local message "
+ messageId);
}
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
// We may have changed the conversation list
MessagingContentProvider.notifyConversationListChanged();
final Uri messageUri = message.getSmsMessageUri();
if (messageUri != null) {
// Delete from telephony DB
count = MmsUtils.deleteMessage(messageUri);
if (count > 0) {
LogUtil.i(TAG, "DeleteMessageAction: Deleted telephony message "
+ messageUri);
} else {
LogUtil.w(TAG, "DeleteMessageAction: Could not delete message from "
+ "telephony: messageId = " + messageId + ", telephony uri = "
+ messageUri);
}
} else {
LogUtil.i(TAG, "DeleteMessageAction: Local message " + messageId
+ " has no telephony uri.");
}
} else {
LogUtil.w(TAG, "DeleteMessageAction: Message " + messageId + " no longer exists");
}
}
return null;
}
/**
* Delete the message.
*/
@Override
protected Object executeAction() {
requestBackgroundWork();
return null;
}
private DeleteMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DeleteMessageAction> CREATOR
= new Parcelable.Creator<DeleteMessageAction>() {
@Override
public DeleteMessageAction createFromParcel(final Parcel in) {
return new DeleteMessageAction(in);
}
@Override
public DeleteMessageAction[] newArray(final int size) {
return new DeleteMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,340 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
/**
* Downloads an MMS message.
* <p>
* This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to
* access the EXTRA_* fields for setting up the 'downloaded' pending intent.
*/
public class DownloadMmsAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Interface for DownloadMmsAction listeners
*/
public interface DownloadMmsActionListener {
@RunsOnMainThread
abstract void onDownloadMessageStarting(final ActionMonitor monitor,
final Object data, final MessageData message);
@RunsOnMainThread
abstract void onDownloadMessageSucceeded(final ActionMonitor monitor,
final Object data, final MessageData message);
@RunsOnMainThread
abstract void onDownloadMessageFailed(final ActionMonitor monitor,
final Object data, final MessageData message);
}
/**
* Queue download of an mms notification message (can only be called during execute of action)
*/
static boolean queueMmsForDownloadInBackground(final String messageId,
final Action processingAction) {
// When this method is being called, it is always from auto download
final DownloadMmsAction action = new DownloadMmsAction();
// This could queue nothing
return action.queueAction(messageId, processingAction);
}
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_PARTICIPANT_ID = "participant_id";
private static final String KEY_CONTENT_LOCATION = "content_location";
private static final String KEY_TRANSACTION_ID = "transaction_id";
private static final String KEY_NOTIFICATION_URI = "notification_uri";
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
private static final String KEY_AUTO_DOWNLOAD = "auto_download";
private static final String KEY_FAILURE_STATUS = "failure_status";
// Values we attach to the pending intent that's fired when the message is downloaded.
// Only applicable when downloading via the platform APIs on L+.
public static final String EXTRA_MESSAGE_ID = "message_id";
public static final String EXTRA_CONTENT_URI = "content_uri";
public static final String EXTRA_NOTIFICATION_URI = "notification_uri";
public static final String EXTRA_SUB_ID = "sub_id";
public static final String EXTRA_SUB_PHONE_NUMBER = "sub_phone_number";
public static final String EXTRA_TRANSACTION_ID = "transaction_id";
public static final String EXTRA_CONTENT_LOCATION = "content_location";
public static final String EXTRA_AUTO_DOWNLOAD = "auto_download";
public static final String EXTRA_RECEIVED_TIMESTAMP = "received_timestamp";
public static final String EXTRA_CONVERSATION_ID = "conversation_id";
public static final String EXTRA_PARTICIPANT_ID = "participant_id";
public static final String EXTRA_STATUS_IF_FAILED = "status_if_failed";
private DownloadMmsAction() {
super();
}
@Override
protected Object executeAction() {
Assert.fail("DownloadMmsAction must be queued rather than started");
return null;
}
protected boolean queueAction(final String messageId, final Action processingAction) {
actionParameters.putString(KEY_MESSAGE_ID, messageId);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Read the message from local db
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
if (message != null && message.canDownloadMessage()) {
final Uri notificationUri = message.getSmsMessageUri();
final String conversationId = message.getConversationId();
final int status = message.getStatus();
final String selfId = message.getSelfId();
final ParticipantData self = BugleDatabaseOperations
.getExistingParticipant(db, selfId);
final int subId = self.getSubId();
actionParameters.putInt(KEY_SUB_ID, subId);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putString(KEY_PARTICIPANT_ID, message.getParticipantId());
actionParameters.putString(KEY_CONTENT_LOCATION, message.getMmsContentLocation());
actionParameters.putString(KEY_TRANSACTION_ID, message.getMmsTransactionId());
actionParameters.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
actionParameters.putBoolean(KEY_AUTO_DOWNLOAD, isAutoDownload(status));
final long now = System.currentTimeMillis();
if (message.getInDownloadWindow(now)) {
// We can still retry
actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination());
final int downloadingStatus = getDownloadingStatus(status);
// Update message status to indicate downloading.
updateMessageStatus(notificationUri, messageId, conversationId,
downloadingStatus, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED);
// Pre-compute the next status when failed so we don't have to load from db again
actionParameters.putInt(KEY_FAILURE_STATUS, getFailureStatus(downloadingStatus));
// Actual download happens in background
processingAction.requestBackgroundWork(this);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG,
"DownloadMmsAction: Queued download of MMS message " + messageId);
}
return true;
} else {
LogUtil.w(TAG, "DownloadMmsAction: Download of MMS message " + messageId
+ " failed (outside download window)");
// Retries depleted and we failed. Update the message status so we won't retry again
updateMessageStatus(notificationUri, messageId, conversationId,
MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED,
MessageData.RAW_TELEPHONY_STATUS_UNDEFINED);
if (status == MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD) {
// For auto download failure, we should send a DEFERRED NotifyRespInd
// to carrier to indicate we will manual download later
ProcessDownloadedMmsAction.sendDeferredRespStatus(
messageId, message.getMmsTransactionId(),
message.getMmsContentLocation(), subId);
return true;
}
}
}
return false;
}
/**
* Find out the auto download state of this message based on its starting status
*
* @param status The starting status of the message.
* @return True if this is a message doing auto downloading, false otherwise
*/
private static boolean isAutoDownload(final int status) {
switch (status) {
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
return false;
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
return true;
default:
Assert.fail("isAutoDownload: invalid input status " + status);
return false;
}
}
/**
* Get the corresponding downloading status based on the starting status of the message
*
* @param status The starting status of the message.
* @return The downloading status
*/
private static int getDownloadingStatus(final int status) {
switch (status) {
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING;
case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
return MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING;
default:
Assert.fail("isAutoDownload: invalid input status " + status);
return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING;
}
}
/**
* Get the corresponding failed status based on the current downloading status
*
* @param status The downloading status
* @return The status the message should have if downloading failed
*/
private static int getFailureStatus(final int status) {
switch (status) {
case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
return MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD;
case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD;
default:
Assert.fail("isAutoDownload: invalid input status " + status);
return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD;
}
}
@Override
protected Bundle doBackgroundWork() {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final Uri notificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI);
final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD);
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID);
final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS);
final long receivedTimestampRoundedToSecond =
1000 * ((System.currentTimeMillis() + 500) / 1000);
LogUtil.i(TAG, "DownloadMmsAction: Downloading MMS message " + messageId
+ " (" + (autoDownload ? "auto" : "manual") + ")");
// Bundle some values we'll need after the message is downloaded (via platform APIs)
final Bundle extras = new Bundle();
extras.putString(EXTRA_MESSAGE_ID, messageId);
extras.putString(EXTRA_CONVERSATION_ID, conversationId);
extras.putString(EXTRA_PARTICIPANT_ID, participantId);
extras.putInt(EXTRA_STATUS_IF_FAILED, statusIfFailed);
// Start the download
final MmsUtils.StatusPlusUri status = MmsUtils.downloadMmsMessage(context,
notificationUri, subId, subPhoneNumber, transactionId, contentLocation,
autoDownload, receivedTimestampRoundedToSecond / 1000L, extras);
if (status == MmsUtils.STATUS_PENDING) {
// Async download; no status yet
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "DownloadMmsAction: Downloading MMS message " + messageId
+ " asynchronously; waiting for pending intent to signal completion");
}
} else {
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(receivedTimestampRoundedToSecond);
// Handle downloaded message
ProcessDownloadedMmsAction.processMessageDownloadFastFailed(messageId,
notificationUri, conversationId, participantId, contentLocation, subId,
subPhoneNumber, statusIfFailed, autoDownload, transactionId,
status.resultCode);
}
return null;
}
@Override
protected Object processBackgroundResponse(final Bundle response) {
// Nothing to do here; post-download actions handled by ProcessDownloadedMmsAction
return null;
}
@Override
protected Object processBackgroundFailure() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID);
final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS);
final int subId = actionParameters.getInt(KEY_SUB_ID);
ProcessDownloadedMmsAction.processDownloadActionFailure(messageId,
MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
conversationId, participantId, statusIfFailed, subId, transactionId);
return null;
}
static void updateMessageStatus(final Uri messageUri, final String messageId,
final String conversationId, final int status, final int rawStatus) {
final Context context = Factory.get().getApplicationContext();
// Downloading status just kept in local DB but need to fix up telephony DB first
if (status == MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING ||
status == MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) {
MmsUtils.clearMmsStatus(context, messageUri);
}
// Then mark downloading status in our local DB
final ContentValues values = new ContentValues();
values.put(MessageColumns.STATUS, status);
values.put(MessageColumns.RAW_TELEPHONY_STATUS, rawStatus);
final DatabaseWrapper db = DataModel.get().getDatabase();
BugleDatabaseOperations.updateMessageRowIfExists(db, messageId, values);
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
private DownloadMmsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DownloadMmsAction> CREATOR
= new Parcelable.Creator<DownloadMmsAction>() {
@Override
public DownloadMmsAction createFromParcel(final Parcel in) {
return new DownloadMmsAction(in);
}
@Override
public DownloadMmsAction[] newArray(final int size) {
return new DownloadMmsAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.util.DebugUtils;
import com.android.messaging.util.LogUtil;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class DumpDatabaseAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static final String DUMP_NAME = "db_copy.db";
private static final int BUFFER_SIZE = 16384;
/**
* Copy the database to external storage
*/
public static void dumpDatabase() {
final DumpDatabaseAction action = new DumpDatabaseAction();
action.start();
}
private DumpDatabaseAction() {
}
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final String dbName = DatabaseHelper.DATABASE_NAME;
BufferedOutputStream bos = null;
BufferedInputStream bis = null;
long originalSize = 0;
final File inFile = context.getDatabasePath(dbName);
if (inFile.exists() && inFile.isFile()) {
originalSize = inFile.length();
}
final File outFile = DebugUtils.getDebugFile(DUMP_NAME, true);
if (outFile != null) {
int totalBytes = 0;
try {
bos = new BufferedOutputStream(new FileOutputStream(outFile));
bis = new BufferedInputStream(new FileInputStream(inFile));
final byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = bis.read(buffer)) > 0) {
bos.write(buffer, 0, bytesRead);
totalBytes += bytesRead;
}
} catch (final IOException e) {
LogUtil.w(TAG, "Exception copying the database;"
+ " destination may not be complete.", e);
} finally {
if (bos != null) {
try {
bos.close();
} catch (final IOException e) {
// Nothing to do
}
}
if (bis != null) {
try {
bis.close();
} catch (final IOException e) {
// Nothing to do
}
}
DebugUtils.ensureReadable(outFile);
LogUtil.i(TAG, "Dump complete; orig size: " + originalSize +
", copy size: " + totalBytes);
}
}
return null;
}
private DumpDatabaseAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<DumpDatabaseAction> CREATOR
= new Parcelable.Creator<DumpDatabaseAction>() {
@Override
public DumpDatabaseAction createFromParcel(final Parcel in) {
return new DumpDatabaseAction(in);
}
@Override
public DumpDatabaseAction[] newArray(final int size) {
return new DumpDatabaseAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
/**
* Action used to fixup actively downloading or sending status at startup - just in case we
* crash - never run this when a message might actually be sending or downloading.
*/
public class FixupMessageStatusOnStartupAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
public static void fixupMessageStatus() {
final FixupMessageStatusOnStartupAction action = new FixupMessageStatusOnStartupAction();
action.start();
}
private FixupMessageStatusOnStartupAction() {
}
@Override
protected Object executeAction() {
// Now mark any messages in active sending or downloading state as inactive
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
int downloadFailedCnt = 0;
int sendFailedCnt = 0;
try {
// For both sending and downloading messages, let's assume they failed.
// For MMS sent/downloaded via platform, the sent/downloaded pending intent
// may come back. That will update the message. User may see the message
// in wrong status within a short window if that happens. But this should
// rarely happen. This is a simple solution to situations like app gets killed
// while the pending intent is still in the fly. Alternatively, we could
// keep the status for platform sent/downloaded MMS and timeout these messages.
// But that is much more complex.
final ContentValues values = new ContentValues();
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED);
downloadFailedCnt += db.update(DatabaseHelper.MESSAGES_TABLE, values,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
new String[]{
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING)
});
values.clear();
values.clear();
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_OUTGOING_FAILED);
sendFailedCnt = db.update(DatabaseHelper.MESSAGES_TABLE, values,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
new String[]{
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)
});
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
LogUtil.i(TAG, "Fixup: Send failed - " + sendFailedCnt
+ " Download failed - " + downloadFailedCnt);
// Don't send contentObserver notifications as displayed text should not change
return null;
}
private FixupMessageStatusOnStartupAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<FixupMessageStatusOnStartupAction> CREATOR
= new Parcelable.Creator<FixupMessageStatusOnStartupAction>() {
@Override
public FixupMessageStatusOnStartupAction createFromParcel(final Parcel in) {
return new FixupMessageStatusOnStartupAction(in);
}
@Override
public FixupMessageStatusOnStartupAction[] newArray(final int size) {
return new FixupMessageStatusOnStartupAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,173 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
import com.android.messaging.datamodel.data.LaunchConversationData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
/**
* Action used to get or create a conversation for a list of conversation participants.
*/
public class GetOrCreateConversationAction extends Action implements Parcelable {
/**
* Interface for GetOrCreateConversationAction listeners
*/
public interface GetOrCreateConversationActionListener {
@RunsOnMainThread
abstract void onGetOrCreateConversationSucceeded(final ActionMonitor monitor,
final Object data, final String conversationId);
@RunsOnMainThread
abstract void onGetOrCreateConversationFailed(final ActionMonitor monitor,
final Object data);
}
public static GetOrCreateConversationActionMonitor getOrCreateConversation(
final ArrayList<ParticipantData> participants, final Object data,
final GetOrCreateConversationActionListener listener) {
final GetOrCreateConversationActionMonitor monitor = new
GetOrCreateConversationActionMonitor(data, listener);
final GetOrCreateConversationAction action = new GetOrCreateConversationAction(participants,
monitor.getActionKey());
action.start(monitor);
return monitor;
}
public static GetOrCreateConversationActionMonitor getOrCreateConversation(
final String[] recipients, final Object data, final LaunchConversationData listener) {
final ArrayList<ParticipantData> participants = new ArrayList<>();
for (String recipient : recipients) {
recipient = recipient.trim();
if (!TextUtils.isEmpty(recipient)) {
participants.add(ParticipantData.getFromRawPhoneBySystemLocale(recipient));
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "getOrCreateConversation hit empty recipient");
}
}
return getOrCreateConversation(participants, data, listener);
}
private static final String KEY_PARTICIPANTS_LIST = "participants_list";
private GetOrCreateConversationAction(final ArrayList<ParticipantData> participants,
final String actionKey) {
super(actionKey);
actionParameters.putParcelableArrayList(KEY_PARTICIPANTS_LIST, participants);
}
/**
* Lookup the conversation or create a new one.
*/
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
// First find the thread id for this list of participants.
final ArrayList<ParticipantData> participants =
actionParameters.getParcelableArrayList(KEY_PARTICIPANTS_LIST);
BugleDatabaseOperations.sanitizeConversationParticipants(participants);
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants);
final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(),
recipients);
if (threadId < 0) {
LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't create a threadId in SMS db for numbers : " +
LogUtil.sanitizePII(recipients.toString()));
// TODO: Add a better way to indicate an error from executeAction.
return null;
}
final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
false, participants, false, false, null);
return conversationId;
}
/**
* A monitor that notifies a listener upon completion
*/
public static class GetOrCreateConversationActionMonitor extends ActionMonitor
implements ActionCompletedListener {
private final GetOrCreateConversationActionListener mListener;
GetOrCreateConversationActionMonitor(final Object data,
final GetOrCreateConversationActionListener listener) {
super(STATE_CREATED, generateUniqueActionKey("GetOrCreateConversationAction"), data);
setCompletedListener(this);
mListener = listener;
}
@Override
public void onActionSucceeded(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
if (result == null) {
mListener.onGetOrCreateConversationFailed(monitor, data);
} else {
mListener.onGetOrCreateConversationSucceeded(monitor, data, (String) result);
}
}
@Override
public void onActionFailed(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
// TODO: Currently onActionFailed is only called if there is an error in
// processing requests, not for errors in the local processing.
Assert.fail("Unreachable");
mListener.onGetOrCreateConversationFailed(monitor, data);
}
}
private GetOrCreateConversationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<GetOrCreateConversationAction> CREATOR
= new Parcelable.Creator<GetOrCreateConversationAction>() {
@Override
public GetOrCreateConversationAction createFromParcel(final Parcel in) {
return new GetOrCreateConversationAction(in);
}
@Override
public GetOrCreateConversationAction[] newArray(final int size) {
return new GetOrCreateConversationAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,94 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.sms.SmsReleaseStorage;
import com.android.messaging.util.Assert;
/**
* Action used to handle low storage related issues on the device.
*/
public class HandleLowStorageAction extends Action implements Parcelable {
private static final int SUB_OP_CODE_CLEAR_MEDIA_MESSAGES = 100;
private static final int SUB_OP_CODE_CLEAR_OLD_MESSAGES = 101;
public static void handleDeleteMediaMessages(final long durationInMillis) {
final HandleLowStorageAction action = new HandleLowStorageAction(
SUB_OP_CODE_CLEAR_MEDIA_MESSAGES, durationInMillis);
action.start();
}
public static void handleDeleteOldMessages(final long durationInMillis) {
final HandleLowStorageAction action = new HandleLowStorageAction(
SUB_OP_CODE_CLEAR_OLD_MESSAGES, durationInMillis);
action.start();
}
private static final String KEY_SUB_OP_CODE = "sub_op_code";
private static final String KEY_CUTOFF_DURATION_MILLIS = "cutoff_duration_millis";
private HandleLowStorageAction(final int subOpcode, final long durationInMillis) {
super();
actionParameters.putInt(KEY_SUB_OP_CODE, subOpcode);
actionParameters.putLong(KEY_CUTOFF_DURATION_MILLIS, durationInMillis);
}
@Override
protected Object executeAction() {
final int subOpCode = actionParameters.getInt(KEY_SUB_OP_CODE);
final long durationInMillis = actionParameters.getLong(KEY_CUTOFF_DURATION_MILLIS);
switch (subOpCode) {
case SUB_OP_CODE_CLEAR_MEDIA_MESSAGES:
SmsReleaseStorage.deleteMessages(0, durationInMillis);
break;
case SUB_OP_CODE_CLEAR_OLD_MESSAGES:
SmsReleaseStorage.deleteMessages(1, durationInMillis);
break;
default:
Assert.fail("Unsupported action type!");
break;
}
return true;
}
private HandleLowStorageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<HandleLowStorageAction> CREATOR
= new Parcelable.Creator<HandleLowStorageAction>() {
@Override
public HandleLowStorageAction createFromParcel(final Parcel in) {
return new HandleLowStorageAction(in);
}
@Override
public HandleLowStorageAction[] newArray(final int size) {
return new HandleLowStorageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,480 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.ArrayList;
import java.util.List;
/**
* Action used to convert a draft message to an outgoing message. Its writes SMS messages to
* the telephony db, but {@link SendMessageAction} is responsible for inserting MMS message into
* the telephony DB. The latter also does the actual sending of the message in the background.
* The latter is also responsible for re-sending a failed message.
*/
public class InsertNewMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static long sLastSentMessageTimestamp = -1;
/**
* Insert message (no listener)
*/
public static void insertNewMessage(final MessageData message) {
final InsertNewMessageAction action = new InsertNewMessageAction(message);
action.start();
}
/**
* Insert message (no listener) with a given non-default subId.
*/
public static void insertNewMessage(final MessageData message, final int subId) {
Assert.isFalse(subId == ParticipantData.DEFAULT_SELF_SUB_ID);
final InsertNewMessageAction action = new InsertNewMessageAction(message, subId);
action.start();
}
/**
* Insert message (no listener)
*/
public static void insertNewMessage(final int subId, final String recipients,
final String messageText, final String subject) {
final InsertNewMessageAction action = new InsertNewMessageAction(
subId, recipients, messageText, subject);
action.start();
}
public static long getLastSentMessageTimestamp() {
return sLastSentMessageTimestamp;
}
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_MESSAGE = "message";
private static final String KEY_RECIPIENTS = "recipients";
private static final String KEY_MESSAGE_TEXT = "message_text";
private static final String KEY_SUBJECT_TEXT = "subject_text";
private InsertNewMessageAction(final MessageData message) {
this(message, ParticipantData.DEFAULT_SELF_SUB_ID);
actionParameters.putParcelable(KEY_MESSAGE, message);
}
private InsertNewMessageAction(final MessageData message, final int subId) {
super();
actionParameters.putParcelable(KEY_MESSAGE, message);
actionParameters.putInt(KEY_SUB_ID, subId);
}
private InsertNewMessageAction(final int subId, final String recipients,
final String messageText, final String subject) {
super();
if (TextUtils.isEmpty(recipients) || TextUtils.isEmpty(messageText)) {
Assert.fail("InsertNewMessageAction: Can't have empty recipients or message");
}
actionParameters.putInt(KEY_SUB_ID, subId);
actionParameters.putString(KEY_RECIPIENTS, recipients);
actionParameters.putString(KEY_MESSAGE_TEXT, messageText);
actionParameters.putString(KEY_SUBJECT_TEXT, subject);
}
/**
* Add message to database in pending state and queue actual sending
*/
@Override
protected Object executeAction() {
LogUtil.i(TAG, "InsertNewMessageAction: inserting new message");
MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
if (message == null) {
LogUtil.i(TAG, "InsertNewMessageAction: Creating MessageData with provided data");
message = createMessage();
if (message == null) {
LogUtil.w(TAG, "InsertNewMessageAction: Could not create MessageData");
return null;
}
}
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = message.getConversationId();
final ParticipantData self = getSelf(db, conversationId, message);
if (self == null) {
return null;
}
message.bindSelfId(self.getId());
// If the user taps the Send button before the conversation draft is created/loaded by
// ReadDraftDataAction (maybe the action service thread was busy), the MessageData may not
// have the participant id set. It should be equal to the self id, so we'll use that.
if (message.getParticipantId() == null) {
message.bindParticipantId(self.getId());
}
final long timestamp = System.currentTimeMillis();
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
if (recipients.size() < 1) {
LogUtil.w(TAG, "InsertNewMessageAction: message recipients is empty");
return null;
}
final int subId = self.getSubId();
// TODO: Work out whether to send with SMS or MMS (taking into account recipients)?
final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS);
if (isSms) {
String sendingConversationId = conversationId;
if (recipients.size() > 1) {
// Broadcast SMS - put message in "fake conversation" before farming out to real 1:1
final long laterTimestamp = timestamp + 1;
// Send a single message
insertBroadcastSmsMessage(conversationId, message, subId,
laterTimestamp, recipients);
sendingConversationId = null;
}
for (final String recipient : recipients) {
// Start actual sending
insertSendingSmsMessage(message, subId, recipient,
timestamp, sendingConversationId);
}
// Can now clear draft from conversation (deleting attachments if necessary)
BugleDatabaseOperations.updateDraftMessageData(db, conversationId,
null /* message */, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
} else {
final long timestampRoundedToSecond = 1000 * ((timestamp + 500) / 1000);
// Write place holder message directly referencing parts from the draft
final MessageData messageToSend = insertSendingMmsMessage(conversationId,
message, timestampRoundedToSecond);
// Can now clear draft from conversation (preserving attachments which are now
// referenced by messageToSend)
BugleDatabaseOperations.updateDraftMessageData(db, conversationId,
messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT);
}
MessagingContentProvider.notifyConversationListChanged();
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
}
private ParticipantData getSelf(
final DatabaseWrapper db, final String conversationId, final MessageData message) {
ParticipantData self;
// Check if we are asked to bind to a non-default subId. This is directly passed in from
// the UI thread so that the sub id may be locked as soon as the user clicks on the Send
// button.
final int requestedSubId = actionParameters.getInt(
KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
if (requestedSubId != ParticipantData.DEFAULT_SELF_SUB_ID) {
self = BugleDatabaseOperations.getOrCreateSelf(db, requestedSubId);
} else {
String selfId = message.getSelfId();
if (selfId == null) {
// The conversation draft provides no self id hint, meaning that 1) conversation
// self id was not loaded AND 2) the user didn't pick a SIM from the SIM selector.
// In this case, use the conversation's self id.
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
if (conversation != null) {
selfId = conversation.getSelfId();
} else {
LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId +
"already deleted before sending draft message " +
message.getMessageId() + ". Aborting InsertNewMessageAction.");
return null;
}
}
// We do not use SubscriptionManager.DEFAULT_SUB_ID for sending a message, so we need
// to bind the message to the system default subscription if it's unbound.
final ParticipantData unboundSelf = BugleDatabaseOperations.getExistingParticipant(
db, selfId);
if (unboundSelf.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID
&& OsUtil.isAtLeastL_MR1()) {
final int defaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId();
self = BugleDatabaseOperations.getOrCreateSelf(db, defaultSubId);
} else {
self = unboundSelf;
}
}
return self;
}
/** Create MessageData using KEY_RECIPIENTS, KEY_MESSAGE_TEXT and KEY_SUBJECT */
private MessageData createMessage() {
// First find the thread id for this list of participants.
final String recipientsList = actionParameters.getString(KEY_RECIPIENTS);
final String messageText = actionParameters.getString(KEY_MESSAGE_TEXT);
final String subjectText = actionParameters.getString(KEY_SUBJECT_TEXT);
final int subId = actionParameters.getInt(
KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final ArrayList<ParticipantData> participants = new ArrayList<>();
for (final String recipient : recipientsList.split(",")) {
participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, subId));
}
if (participants.size() == 0) {
Assert.fail("InsertNewMessage: Empty participants");
return null;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
BugleDatabaseOperations.sanitizeConversationParticipants(participants);
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants);
if (recipients.size() == 0) {
Assert.fail("InsertNewMessage: Empty recipients");
return null;
}
final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(),
recipients);
if (threadId < 0) {
Assert.fail("InsertNewMessage: Couldn't get threadId in SMS db for these recipients: "
+ recipients.toString());
// TODO: How do we fail the action?
return null;
}
final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
false, participants, false, false, null);
final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId);
if (TextUtils.isEmpty(subjectText)) {
return MessageData.createDraftSmsMessage(conversationId, self.getId(), messageText);
} else {
return MessageData.createDraftMmsMessage(conversationId, self.getId(), messageText,
subjectText);
}
}
private void insertBroadcastSmsMessage(final String conversationId,
final MessageData message, final int subId, final long laterTimestamp,
final ArrayList<String> recipients) {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "InsertNewMessageAction: Inserting broadcast SMS message "
+ message.getMessageId());
}
final Context context = Factory.get().getApplicationContext();
final DatabaseWrapper db = DataModel.get().getDatabase();
// Inform sync that message is being added at timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(laterTimestamp);
final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
final String address = TextUtils.join(" ", recipients);
final String messageText = message.getMessageText();
// Insert message into telephony database sms message table
final Uri messageUri = MmsUtils.insertSmsMessage(context,
Telephony.Sms.CONTENT_URI,
subId,
address,
messageText,
laterTimestamp,
Telephony.Sms.STATUS_COMPLETE,
Telephony.Sms.MESSAGE_TYPE_SENT, threadId);
if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) {
db.beginTransaction();
try {
message.updateSendingMessage(conversationId, messageUri, laterTimestamp);
message.markMessageSent(laterTimestamp);
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), laterTimestamp,
false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "InsertNewMessageAction: Inserted broadcast SMS message "
+ message.getMessageId() + ", uri = " + message.getSmsMessageUri());
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
} else {
// Ignore error as we only really care about the individual messages?
LogUtil.e(TAG,
"InsertNewMessageAction: No uri for broadcast SMS " + message.getMessageId()
+ " inserted into telephony DB");
}
}
/**
* Insert SMS messaging into our database and telephony db.
*/
private MessageData insertSendingSmsMessage(final MessageData content, final int subId,
final String recipient, final long timestamp, final String sendingConversationId) {
sLastSentMessageTimestamp = timestamp;
final Context context = Factory.get().getApplicationContext();
// Inform sync that message is being added at timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(timestamp);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Send a single message
long threadId;
String conversationId;
if (sendingConversationId == null) {
// For 1:1 message generated sending broadcast need to look up threadId+conversationId
threadId = MmsUtils.getOrCreateSmsThreadId(context, recipient);
conversationId = BugleDatabaseOperations.getOrCreateConversationFromRecipient(
db, threadId, false /* sender blocked */,
ParticipantData.getFromRawPhoneBySimLocale(recipient, subId));
} else {
// Otherwise just look up threadId
threadId = BugleDatabaseOperations.getThreadId(db, sendingConversationId);
conversationId = sendingConversationId;
}
final String messageText = content.getMessageText();
// Insert message into telephony database sms message table
final Uri messageUri = MmsUtils.insertSmsMessage(context,
Telephony.Sms.CONTENT_URI,
subId,
recipient,
messageText,
timestamp,
Telephony.Sms.STATUS_NONE,
Telephony.Sms.MESSAGE_TYPE_SENT, threadId);
MessageData message = null;
if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) {
db.beginTransaction();
try {
message = MessageData.createDraftSmsMessage(conversationId,
content.getSelfId(), messageText);
message.updateSendingMessage(conversationId, messageUri, timestamp);
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
// Do not update the conversation summary to reflect autogenerated 1:1 messages
if (sendingConversationId != null) {
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), timestamp,
false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "InsertNewMessageAction: Inserted SMS message "
+ message.getMessageId() + " (uri = " + message.getSmsMessageUri()
+ ", timestamp = " + message.getReceivedTimeStamp() + ")");
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
} else {
LogUtil.e(TAG, "InsertNewMessageAction: No uri for SMS inserted into telephony DB");
}
return message;
}
/**
* Insert MMS messaging into our database.
*/
private MessageData insertSendingMmsMessage(final String conversationId,
final MessageData message, final long timestamp) {
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
final List<MessagePartData> attachmentsUpdated = new ArrayList<>();
try {
sLastSentMessageTimestamp = timestamp;
// Insert "draft" message as placeholder until the final message is written to
// the telephony db
message.updateSendingMessage(conversationId, null/*messageUri*/, timestamp);
// No need to inform SyncManager as message currently has no Uri...
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), timestamp,
false /* senderBlocked */, false /* shouldAutoSwitchSelfId */);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "InsertNewMessageAction: Inserted MMS message "
+ message.getMessageId() + " (timestamp = " + timestamp + ")");
}
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
return message;
}
private InsertNewMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<InsertNewMessageAction> CREATOR
= new Parcelable.Creator<InsertNewMessageAction>() {
@Override
public InsertNewMessageAction createFromParcel(final Parcel in) {
return new InsertNewMessageAction(in);
}
@Override
public InsertNewMessageAction[] newArray(final int size) {
return new InsertNewMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Threads;
import android.provider.Telephony.ThreadsColumns;
import com.android.messaging.Factory;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.util.DebugUtils;
import com.android.messaging.util.LogUtil;
public class LogTelephonyDatabaseAction extends Action implements Parcelable {
// Because we use sanitizePII, we should also use BUGLE_TAG
private static final String TAG = LogUtil.BUGLE_TAG;
private static final String[] ALL_THREADS_PROJECTION = {
Threads._ID,
Threads.DATE,
Threads.MESSAGE_COUNT,
Threads.RECIPIENT_IDS,
Threads.SNIPPET,
Threads.SNIPPET_CHARSET,
Threads.READ,
Threads.ERROR,
Threads.HAS_ATTACHMENT };
// Constants from the Telephony Database
private static final int ID = 0;
private static final int DATE = 1;
private static final int MESSAGE_COUNT = 2;
private static final int RECIPIENT_IDS = 3;
private static final int SNIPPET = 4;
private static final int SNIPPET_CHAR_SET = 5;
private static final int READ = 6;
private static final int ERROR = 7;
private static final int HAS_ATTACHMENT = 8;
/**
* Log telephony data to logcat
*/
public static void dumpDatabase() {
final LogTelephonyDatabaseAction action = new LogTelephonyDatabaseAction();
action.start();
}
private LogTelephonyDatabaseAction() {
}
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
if (!DebugUtils.isDebugEnabled()) {
LogUtil.e(TAG, "Can't log telephony database unless debugging is enabled");
return null;
}
if (!LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.w(TAG, "Can't log telephony database unless DEBUG is turned on for TAG: " +
TAG);
return null;
}
LogUtil.d(TAG, "\n");
LogUtil.d(TAG, "Dump of canoncial_addresses table");
LogUtil.d(TAG, "*********************************");
Cursor cursor = SqliteWrapper.query(context, context.getContentResolver(),
Uri.parse("content://mms-sms/canonical-addresses"), null, null, null, null);
if (cursor == null) {
LogUtil.w(TAG, "null Cursor in content://mms-sms/canonical-addresses");
} else {
try {
while (cursor.moveToNext()) {
long id = cursor.getLong(0);
String number = cursor.getString(1);
LogUtil.d(TAG, LogUtil.sanitizePII("id: " + id + " number: " + number));
}
} finally {
cursor.close();
}
}
LogUtil.d(TAG, "\n");
LogUtil.d(TAG, "Dump of threads table");
LogUtil.d(TAG, "*********************");
cursor = SqliteWrapper.query(context, context.getContentResolver(),
Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(),
ALL_THREADS_PROJECTION, null, null, "date ASC");
try {
while (cursor.moveToNext()) {
LogUtil.d(TAG, LogUtil.sanitizePII("threadId: " + cursor.getLong(ID) +
" " + ThreadsColumns.DATE + " : " + cursor.getLong(DATE) +
" " + ThreadsColumns.MESSAGE_COUNT + " : " + cursor.getInt(MESSAGE_COUNT) +
" " + ThreadsColumns.SNIPPET + " : " + cursor.getString(SNIPPET) +
" " + ThreadsColumns.READ + " : " + cursor.getInt(READ) +
" " + ThreadsColumns.ERROR + " : " + cursor.getInt(ERROR) +
" " + ThreadsColumns.HAS_ATTACHMENT + " : " +
cursor.getInt(HAS_ATTACHMENT) +
" " + ThreadsColumns.RECIPIENT_IDS + " : " +
cursor.getString(RECIPIENT_IDS)));
}
} finally {
cursor.close();
}
return null;
}
private LogTelephonyDatabaseAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<LogTelephonyDatabaseAction> CREATOR
= new Parcelable.Creator<LogTelephonyDatabaseAction>() {
@Override
public LogTelephonyDatabaseAction createFromParcel(final Parcel in) {
return new LogTelephonyDatabaseAction(in);
}
@Override
public LogTelephonyDatabaseAction[] newArray(final int size) {
return new LogTelephonyDatabaseAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.LogUtil;
/**
* Action used to mark all the messages in a conversation as read
*/
public class MarkAsReadAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_CONVERSATION_ID = "conversation_id";
/**
* Mark all the messages as read for a particular conversation.
*/
public static void markAsRead(final String conversationId) {
final MarkAsReadAction action = new MarkAsReadAction(conversationId);
action.start();
}
private MarkAsReadAction(final String conversationId) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
// TODO: Consider doing this in background service to avoid delaying other actions
final DatabaseWrapper db = DataModel.get().getDatabase();
// Mark all messages in thread as read in telephony
final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId);
if (threadId != -1) {
MmsUtils.updateSmsReadStatus(threadId, Long.MAX_VALUE);
}
// Update local db
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(MessageColumns.CONVERSATION_ID, conversationId);
values.put(MessageColumns.READ, 1);
values.put(MessageColumns.SEEN, 1); // if they read it, they saw it
final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
"(" + MessageColumns.READ + " !=1 OR " +
MessageColumns.SEEN + " !=1 ) AND " +
MessageColumns.CONVERSATION_ID + "=?",
new String[] { conversationId });
if (count > 0) {
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// After marking messages as read, update the notifications. This will
// clear the now stale notifications.
BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
return null;
}
private MarkAsReadAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<MarkAsReadAction> CREATOR
= new Parcelable.Creator<MarkAsReadAction>() {
@Override
public MarkAsReadAction createFromParcel(final Parcel in) {
return new MarkAsReadAction(in);
}
@Override
public MarkAsReadAction[] newArray(final int size) {
return new MarkAsReadAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.util.LogUtil;
/**
* Action used to mark all messages as seen
*/
public class MarkAsSeenAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_CONVERSATION_ID = "conversation_id";
/**
* Mark all messages as seen.
*/
public static void markAllAsSeen() {
final MarkAsSeenAction action = new MarkAsSeenAction((String) null/*conversationId*/);
action.start();
}
/**
* Mark all messages of a given conversation as seen.
*/
public static void markAsSeen(final String conversationId) {
final MarkAsSeenAction action = new MarkAsSeenAction(conversationId);
action.start();
}
/**
* ctor for MarkAsSeenAction.
* @param conversationId the conversation id for which to mark as seen, or null to mark all
* messages as seen
*/
public MarkAsSeenAction(final String conversationId) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String conversationId =
actionParameters.getString(KEY_CONVERSATION_ID);
final boolean hasSpecificConversation = !TextUtils.isEmpty(conversationId);
// Everything in telephony should already have the seen bit set.
// Possible exception are messages which did not have seen set and
// were sync'ed into bugle.
// Now mark the messages as seen in the bugle db
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(MessageColumns.SEEN, 1);
if (hasSpecificConversation) {
final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
MessageColumns.SEEN + " != 1 AND " +
MessageColumns.CONVERSATION_ID + "=?",
new String[] { conversationId });
if (count > 0) {
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
} else {
db.update(DatabaseHelper.MESSAGES_TABLE, values,
MessageColumns.SEEN + " != 1", null/*selectionArgs*/);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// After marking messages as seen, update the notifications. This will
// clear the now stale notifications.
BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
return null;
}
private MarkAsSeenAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<MarkAsSeenAction> CREATOR
= new Parcelable.Creator<MarkAsSeenAction>() {
@Override
public MarkAsSeenAction createFromParcel(final Parcel in) {
return new MarkAsSeenAction(in);
}
@Override
public MarkAsSeenAction[] newArray(final int size) {
return new MarkAsSeenAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,122 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentUris;
import android.content.ContentValues;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.concurrent.TimeUnit;
public class ProcessDeliveryReportAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_URI = "uri";
private static final String KEY_STATUS = "status";
private ProcessDeliveryReportAction(final Uri uri, final int status) {
actionParameters.putParcelable(KEY_URI, uri);
actionParameters.putInt(KEY_STATUS, status);
}
public static void deliveryReportReceived(final Uri uri, final int status) {
final ProcessDeliveryReportAction action = new ProcessDeliveryReportAction(uri, status);
action.start();
}
@Override
protected Object executeAction() {
final Uri smsMessageUri = actionParameters.getParcelable(KEY_URI);
final int status = actionParameters.getInt(KEY_STATUS);
final DatabaseWrapper db = DataModel.get().getDatabase();
final long messageRowId = ContentUris.parseId(smsMessageUri);
if (messageRowId < 0) {
LogUtil.e(TAG, "ProcessDeliveryReportAction: can't find message");
return null;
}
final long timeSentInMillis = System.currentTimeMillis();
// Update telephony provider
if (smsMessageUri != null) {
MmsUtils.updateSmsStatusAndDateSent(smsMessageUri, status, timeSentInMillis);
}
// Update local message
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
final int bugleStatus = SyncMessageBatch.bugleStatusForSms(true /*outgoing*/,
Telephony.Sms.MESSAGE_TYPE_SENT /* type */, status);
values.put(DatabaseHelper.MessageColumns.STATUS, bugleStatus);
values.put(DatabaseHelper.MessageColumns.SENT_TIMESTAMP,
TimeUnit.MILLISECONDS.toMicros(timeSentInMillis));
final MessageData messageData =
BugleDatabaseOperations.readMessageData(db, smsMessageUri);
// Check the message was not removed before the delivery report comes in
if (messageData != null) {
Assert.isTrue(smsMessageUri.equals(messageData.getSmsMessageUri()));
// Row must exist as was just loaded above (on ActionService thread)
BugleDatabaseOperations.updateMessageRow(db, messageData.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(messageData.getConversationId());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return null;
}
private ProcessDeliveryReportAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessDeliveryReportAction> CREATOR
= new Parcelable.Creator<ProcessDeliveryReportAction>() {
@Override
public ProcessDeliveryReportAction createFromParcel(final Parcel in) {
return new ProcessDeliveryReportAction(in);
}
@Override
public ProcessDeliveryReportAction[] newArray(final int size) {
return new ProcessDeliveryReportAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,573 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.app.Activity;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Mms;
import android.telephony.SmsManager;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DataModelException;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.MmsFileProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.mmslib.pdu.PduHeaders;
import com.android.messaging.mmslib.pdu.RetrieveConf;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.MmsSender;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.google.common.io.Files;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.List;
/**
* Processes an MMS message after it has been downloaded.
* NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure).
*/
public class ProcessDownloadedMmsAction extends Action {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// Always set when message downloaded
private static final String KEY_DOWNLOADED_BY_PLATFORM = "downloaded_by_platform";
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_NOTIFICATION_URI = "notification_uri";
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_PARTICIPANT_ID = "participant_id";
private static final String KEY_STATUS_IF_FAILED = "status_if_failed";
// Set when message downloaded by platform (L+)
private static final String KEY_RESULT_CODE = "result_code";
private static final String KEY_HTTP_STATUS_CODE = "http_status_code";
private static final String KEY_CONTENT_URI = "content_uri";
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
private static final String KEY_TRANSACTION_ID = "transaction_id";
private static final String KEY_CONTENT_LOCATION = "content_location";
private static final String KEY_AUTO_DOWNLOAD = "auto_download";
private static final String KEY_RECEIVED_TIMESTAMP = "received_timestamp";
// Set when message downloaded by us (legacy)
private static final String KEY_STATUS = "status";
private static final String KEY_RAW_STATUS = "raw_status";
private static final String KEY_MMS_URI = "mms_uri";
// Used to send a deferred response in response to auto-download failure
private static final String KEY_SEND_DEFERRED_RESP_STATUS = "send_deferred_resp_status";
// Results passed from background worker to processCompletion
private static final String BUNDLE_REQUEST_STATUS = "request_status";
private static final String BUNDLE_RAW_TELEPHONY_STATUS = "raw_status";
private static final String BUNDLE_MMS_URI = "mms_uri";
// This is called when MMS lib API returns via PendingIntent
public static void processMessageDownloaded(final int resultCode, final Bundle extras) {
final String messageId = extras.getString(DownloadMmsAction.EXTRA_MESSAGE_ID);
final Uri contentUri = extras.getParcelable(DownloadMmsAction.EXTRA_CONTENT_URI);
final Uri notificationUri = extras.getParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI);
final String conversationId = extras.getString(DownloadMmsAction.EXTRA_CONVERSATION_ID);
final String participantId = extras.getString(DownloadMmsAction.EXTRA_PARTICIPANT_ID);
Assert.notNull(messageId);
Assert.notNull(contentUri);
Assert.notNull(notificationUri);
Assert.notNull(conversationId);
Assert.notNull(participantId);
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true);
params.putString(KEY_MESSAGE_ID, messageId);
params.putInt(KEY_RESULT_CODE, resultCode);
params.putInt(KEY_HTTP_STATUS_CODE,
extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0));
params.putParcelable(KEY_CONTENT_URI, contentUri);
params.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
params.putInt(KEY_SUB_ID,
extras.getInt(DownloadMmsAction.EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
params.putString(KEY_SUB_PHONE_NUMBER,
extras.getString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER));
params.putString(KEY_TRANSACTION_ID,
extras.getString(DownloadMmsAction.EXTRA_TRANSACTION_ID));
params.putString(KEY_CONTENT_LOCATION,
extras.getString(DownloadMmsAction.EXTRA_CONTENT_LOCATION));
params.putBoolean(KEY_AUTO_DOWNLOAD,
extras.getBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD));
params.putLong(KEY_RECEIVED_TIMESTAMP,
extras.getLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP));
params.putString(KEY_CONVERSATION_ID, conversationId);
params.putString(KEY_PARTICIPANT_ID, participantId);
params.putInt(KEY_STATUS_IF_FAILED,
extras.getInt(DownloadMmsAction.EXTRA_STATUS_IF_FAILED));
action.start();
}
// This is called for fast failing downloading (due to airplane mode or mobile data )
public static void processMessageDownloadFastFailed(final String messageId,
final Uri notificationUri, final String conversationId, final String participantId,
final String contentLocation, final int subId, final String subPhoneNumber,
final int statusIfFailed, final boolean autoDownload, final String transactionId,
final int resultCode) {
Assert.notNull(messageId);
Assert.notNull(notificationUri);
Assert.notNull(conversationId);
Assert.notNull(participantId);
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true);
params.putString(KEY_MESSAGE_ID, messageId);
params.putInt(KEY_RESULT_CODE, resultCode);
params.putParcelable(KEY_NOTIFICATION_URI, notificationUri);
params.putInt(KEY_SUB_ID, subId);
params.putString(KEY_SUB_PHONE_NUMBER, subPhoneNumber);
params.putString(KEY_CONTENT_LOCATION, contentLocation);
params.putBoolean(KEY_AUTO_DOWNLOAD, autoDownload);
params.putString(KEY_CONVERSATION_ID, conversationId);
params.putString(KEY_PARTICIPANT_ID, participantId);
params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed);
params.putString(KEY_TRANSACTION_ID, transactionId);
action.start();
}
public static void processDownloadActionFailure(final String messageId, final int status,
final int rawStatus, final String conversationId, final String participantId,
final int statusIfFailed, final int subId, final String transactionId) {
Assert.notNull(messageId);
Assert.notNull(conversationId);
Assert.notNull(participantId);
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, false);
params.putString(KEY_MESSAGE_ID, messageId);
params.putInt(KEY_STATUS, status);
params.putInt(KEY_RAW_STATUS, rawStatus);
params.putString(KEY_CONVERSATION_ID, conversationId);
params.putString(KEY_PARTICIPANT_ID, participantId);
params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed);
params.putInt(KEY_SUB_ID, subId);
params.putString(KEY_TRANSACTION_ID, transactionId);
action.start();
}
public static void sendDeferredRespStatus(final String messageId, final String transactionId,
final String contentLocation, final int subId) {
final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction();
final Bundle params = action.actionParameters;
params.putString(KEY_MESSAGE_ID, messageId);
params.putString(KEY_TRANSACTION_ID, transactionId);
params.putString(KEY_CONTENT_LOCATION, contentLocation);
params.putBoolean(KEY_SEND_DEFERRED_RESP_STATUS, true);
params.putInt(KEY_SUB_ID, subId);
action.start();
}
private ProcessDownloadedMmsAction() {
// Callers must use one of the static methods above
}
@Override
protected Object executeAction() {
// Fire up the background worker
requestBackgroundWork();
return null;
}
@Override
protected Bundle doBackgroundWork() throws DataModelException {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
final boolean sendDeferredRespStatus =
actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS, false);
// Send a response indicating that auto-download failed
if (sendDeferredRespStatus) {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "DownloadMmsAction: Auto-download of message " + messageId
+ " failed; sending DEFERRED NotifyRespInd");
}
MmsUtils.sendNotifyResponseForMmsDownload(
context,
subId,
MmsUtils.stringToBytes(transactionId, "UTF-8"),
contentLocation,
PduHeaders.STATUS_DEFERRED);
return null;
}
// Processing a real MMS download
final boolean downloadedByPlatform = actionParameters.getBoolean(
KEY_DOWNLOADED_BY_PLATFORM);
final int status;
int rawStatus = MmsUtils.PDU_HEADER_VALUE_UNDEFINED;
Uri mmsUri = null;
if (downloadedByPlatform) {
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
if (resultCode == Activity.RESULT_OK) {
final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI);
final File downloadedFile = MmsFileProvider.getFile(contentUri);
byte[] downloadedData = null;
try {
downloadedData = Files.toByteArray(downloadedFile);
} catch (final FileNotFoundException e) {
LogUtil.e(TAG, "ProcessDownloadedMmsAction: MMS download file not found: "
+ downloadedFile.getAbsolutePath());
} catch (final IOException e) {
LogUtil.e(TAG, "ProcessDownloadedMmsAction: Error reading MMS download file: "
+ downloadedFile.getAbsolutePath(), e);
}
// Can delete the temp file now
if (downloadedFile.exists()) {
downloadedFile.delete();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessDownloadedMmsAction: Deleted temp file with "
+ "downloaded MMS pdu: " + downloadedFile.getAbsolutePath());
}
}
if (downloadedData != null) {
final RetrieveConf retrieveConf =
MmsSender.parseRetrieveConf(downloadedData, subId);
if (MmsUtils.isDumpMmsEnabled()) {
MmsUtils.dumpPdu(downloadedData, retrieveConf);
}
if (retrieveConf != null) {
// Insert the downloaded MMS into telephony
final Uri notificationUri = actionParameters.getParcelable(
KEY_NOTIFICATION_URI);
final String subPhoneNumber = actionParameters.getString(
KEY_SUB_PHONE_NUMBER);
final boolean autoDownload = actionParameters.getBoolean(
KEY_AUTO_DOWNLOAD);
final long receivedTimestampInSeconds =
actionParameters.getLong(KEY_RECEIVED_TIMESTAMP);
// Inform sync we're adding a message to telephony
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(receivedTimestampInSeconds * 1000L);
final MmsUtils.StatusPlusUri result =
MmsUtils.insertDownloadedMessageAndSendResponse(context,
notificationUri, subId, subPhoneNumber, transactionId,
contentLocation, autoDownload, receivedTimestampInSeconds,
retrieveConf);
status = result.status;
rawStatus = result.rawStatus;
mmsUri = result.uri;
} else {
// Invalid response PDU
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
}
} else {
// Failed to read download file
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
}
} else {
LogUtil.w(TAG, "ProcessDownloadedMmsAction: Platform returned error resultCode: "
+ resultCode);
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode);
}
} else {
// Message was already processed by the internal API, or the download action failed.
// In either case, we just need to copy the status to the response bundle.
status = actionParameters.getInt(KEY_STATUS);
rawStatus = actionParameters.getInt(KEY_RAW_STATUS);
mmsUri = actionParameters.getParcelable(KEY_MMS_URI);
}
final Bundle response = new Bundle();
response.putInt(BUNDLE_REQUEST_STATUS, status);
response.putInt(BUNDLE_RAW_TELEPHONY_STATUS, rawStatus);
response.putParcelable(BUNDLE_MMS_URI, mmsUri);
return response;
}
@Override
protected Object processBackgroundResponse(final Bundle response) {
if (response == null) {
// No message download to process; doBackgroundWork sent a notify deferred response
Assert.isTrue(actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS));
return null;
}
final int status = response.getInt(BUNDLE_REQUEST_STATUS);
final int rawStatus = response.getInt(BUNDLE_RAW_TELEPHONY_STATUS);
final Uri messageUri = response.getParcelable(BUNDLE_MMS_URI);
final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
// Do post-processing on downloaded message
final MessageData message = processResult(status, rawStatus, messageUri);
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
// If we were trying to auto-download but have failed need to send the deferred response
if (autoDownload && message == null && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
sendDeferredRespStatus(messageId, transactionId, contentLocation, subId);
}
if (autoDownload) {
final DatabaseWrapper db = DataModel.get().getDatabase();
MessageData toastMessage = message;
if (toastMessage == null) {
// If the downloaded failed (message is null), then we should announce the
// receiving of the wap push message. Load the wap push message here instead.
toastMessage = BugleDatabaseOperations.readMessageData(db, messageId);
}
if (toastMessage != null) {
final ParticipantData sender = ParticipantData.getFromId(
db, toastMessage.getParticipantId());
BugleActionToasts.onMessageReceived(
toastMessage.getConversationId(), sender, toastMessage);
}
} else {
final boolean success = message != null && status == MmsUtils.MMS_REQUEST_SUCCEEDED;
BugleActionToasts.onSendMessageOrManualDownloadActionCompleted(
// If download failed, use the wap push message's conversation instead
success ? message.getConversationId()
: actionParameters.getString(KEY_CONVERSATION_ID),
success, status, false/*isSms*/, subId, false /*isSend*/);
}
final boolean failed = (messageUri == null);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(failed, this);
if (failed) {
BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS);
}
return message;
}
@Override
protected Object processBackgroundFailure() {
if (actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)) {
// We can early-out for these failures. processResult is only designed to handle
// post-processing of MMS downloads (whether successful or not).
LogUtil.w(TAG,
"ProcessDownloadedMmsAction: Exception while sending deferred NotifyRespInd");
return null;
}
// Background worker threw an exception; require manual retry
processResult(MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
null /* mmsUri */);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true /* failed */,
this);
return null;
}
private MessageData processResult(final int status, final int rawStatus, final Uri mmsUri) {
final Context context = Factory.get().getApplicationContext();
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final Uri mmsNotificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI);
final String notificationConversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final String notificationParticipantId = actionParameters.getString(KEY_PARTICIPANT_ID);
final int statusIfFailed = actionParameters.getInt(KEY_STATUS_IF_FAILED);
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
Assert.notNull(messageId);
LogUtil.i(TAG, "ProcessDownloadedMmsAction: Processed MMS download of message " + messageId
+ "; status is " + MmsUtils.getRequestStatusDescription(status));
DatabaseMessages.MmsMessage mms = null;
if (status == MmsUtils.MMS_REQUEST_SUCCEEDED && mmsUri != null) {
// Delete the initial M-Notification.ind from telephony
SqliteWrapper.delete(context, context.getContentResolver(),
mmsNotificationUri, null, null);
// Read the sent MMS from the telephony provider
mms = MmsUtils.loadMms(mmsUri);
}
boolean messageInFocusedConversation = false;
boolean messageInObservableConversation = false;
String conversationId = null;
MessageData message = null;
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
if (mms != null) {
final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId());
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId);
String from = MmsUtils.getMmsSender(recipients, mms.getUri());
if (from == null) {
LogUtil.w(TAG,
"Downloaded an MMS without sender address; using unknown sender.");
from = ParticipantData.getUnknownSenderDestination();
}
final ParticipantData sender = ParticipantData.getFromRawPhoneBySimLocale(from,
subId);
final String senderParticipantId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender);
if (!senderParticipantId.equals(notificationParticipantId)) {
LogUtil.e(TAG, "ProcessDownloadedMmsAction: Downloaded MMS message "
+ messageId + " has different sender (participantId = "
+ senderParticipantId + ") than notification ("
+ notificationParticipantId + ")");
}
final boolean blockedSender = BugleDatabaseOperations.isBlockedDestination(
db, sender.getNormalizedDestination());
conversationId = BugleDatabaseOperations.getOrCreateConversationFromThreadId(db,
mms.mThreadId, blockedSender, subId);
messageInFocusedConversation =
DataModel.get().isFocusedConversation(conversationId);
messageInObservableConversation =
DataModel.get().isNewMessageObservable(conversationId);
// TODO: Also write these values to the telephony provider
mms.mRead = messageInFocusedConversation;
mms.mSeen = messageInObservableConversation;
// Translate to our format
message = MmsUtils.createMmsMessage(mms, conversationId, senderParticipantId,
selfId, MessageData.BUGLE_STATUS_INCOMING_COMPLETE);
// Update image sizes.
message.updateSizesForImageParts();
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(message.getReceivedTimeStamp());
final MessageData current = BugleDatabaseOperations.readMessageData(db, messageId);
if (current == null) {
LogUtil.w(TAG, "Message deleted prior to update");
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
} else {
// Overwrite existing notification message
message.updateMessageId(messageId);
// Write message
BugleDatabaseOperations.updateMessageInTransaction(db, message);
}
if (!TextUtils.equals(notificationConversationId, conversationId)) {
// If this is a group conversation, the message is moved. So the original
// 1v1 conversation (as referenced by notificationConversationId) could
// be left with no non-draft message. Delete the conversation if that
// happens. See the comment for the method below for why we need to do this.
if (!BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(
db, notificationConversationId)) {
BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(
db, notificationConversationId, messageId,
true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/);
}
}
BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, conversationId,
true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/);
} else {
messageInFocusedConversation =
DataModel.get().isFocusedConversation(notificationConversationId);
// Default to retry status unless status indicates otherwise
int bugleStatus = statusIfFailed;
if (status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) {
bugleStatus = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED;
} else if (status == MmsUtils.MMS_REQUEST_NO_RETRY) {
bugleStatus = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE;
}
DownloadMmsAction.updateMessageStatus(mmsNotificationUri, messageId,
notificationConversationId, bugleStatus, rawStatus);
// Log MMS download failed
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
// Just in case this was the latest message update the summary data
BugleDatabaseOperations.refreshConversationMetadataInTransaction(db,
notificationConversationId, true /*shouldAutoSwitchSelfId*/,
false /*keepArchived*/);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (mmsUri != null) {
// Update mms table with read status now we know the conversation id
final ContentValues values = new ContentValues(1);
values.put(Mms.READ, messageInFocusedConversation);
SqliteWrapper.update(context, context.getContentResolver(), mmsUri, values,
null, null);
}
// Show a notification to let the user know a new message has arrived
BugleNotifications.update(false /*silent*/, conversationId, BugleNotifications.UPDATE_ALL);
// Messages may have changed in two conversations
if (conversationId != null) {
MessagingContentProvider.notifyMessagesChanged(conversationId);
}
MessagingContentProvider.notifyMessagesChanged(notificationConversationId);
MessagingContentProvider.notifyPartsChanged();
return message;
}
private ProcessDownloadedMmsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessDownloadedMmsAction> CREATOR
= new Parcelable.Creator<ProcessDownloadedMmsAction>() {
@Override
public ProcessDownloadedMmsAction createFromParcel(final Parcel in) {
return new ProcessDownloadedMmsAction(in);
}
@Override
public ProcessDownloadedMmsAction[] newArray(final int size) {
return new ProcessDownloadedMmsAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,470 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.os.Parcel;
import android.os.Parcelable;
import android.telephony.ServiceState;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.BugleGservices;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.ConnectivityUtil.ConnectivityListener;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import java.util.HashSet;
import java.util.Set;
/**
* Action used to lookup any messages in the pending send/download state and either fail them or
* retry their action. This action only initiates one retry at a time - further retries should be
* triggered by successful sending of a message, network status change or exponential backoff timer.
*/
public class ProcessPendingMessagesAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final int PENDING_INTENT_REQUEST_CODE = 101;
public static void processFirstPendingMessage() {
// Clear any pending alarms or connectivity events
unregister();
// Clear retry count
setRetry(0);
// Start action
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
action.start();
}
public static void scheduleProcessPendingMessagesAction(final boolean failed,
final Action processingAction) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages"
+ (failed ? "(message failed)" : ""));
// Can safely clear any pending alarms or connectivity events as either an action
// is currently running or we will run now or register if pending actions possible.
unregister();
final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
boolean scheduleAlarm = false;
// If message succeeded and if Bugle is default SMS app just carry on with next message
if (!failed && isDefaultSmsApp) {
// Clear retry attempt count as something just succeeded
setRetry(0);
// Lookup and queue next message for immediate processing by background worker
// iff there are no pending messages this will do nothing and return true.
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
if (action.queueActions(processingAction)) {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
if (processingAction.hasBackgroundActions()) {
LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued");
} else {
LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue");
}
}
// Have queued next action if needed, nothing more to do
return;
}
// In case of error queuing schedule a retry
scheduleAlarm = true;
LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying");
}
if (getHavePendingMessages() || scheduleAlarm) {
// Still have a pending message that needs to be queued for processing
final ConnectivityListener listener = new ConnectivityListener() {
@Override
public void onConnectivityStateChanged(final Context context, final Intent intent) {
final int networkType =
MmsUtils.getConnectivityEventNetworkType(context, intent);
if (networkType != ConnectivityManager.TYPE_MOBILE) {
return;
}
final boolean isConnected = !intent.getBooleanExtra(
ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
// TODO: Should we check in more detail?
if (isConnected) {
onConnected();
}
}
@Override
public void onPhoneStateChanged(final Context context, final int serviceState) {
if (serviceState == ServiceState.STATE_IN_SERVICE) {
onConnected();
}
}
private void onConnected() {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected; starting action");
// Clear any pending alarms or connectivity events but leave attempt count alone
unregister();
// Start action
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
action.start();
}
};
// Read and increment attempt number from shared prefs
final int retryAttempt = getNextRetry();
register(listener, retryAttempt);
} else {
// No more pending messages (presumably the message that failed has expired) or it
// may be possible that a send and a download are already in process.
// Clear retry attempt count.
// TODO Might be premature if send and download in process...
// but worst case means we try to send a bit more often.
setRetry(0);
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "ProcessPendingMessagesAction: No more pending messages");
}
}
}
private static void register(final ConnectivityListener listener, final int retryAttempt) {
int retryNumber = retryAttempt;
// Register to be notified about connectivity changes
DataModel.get().getConnectivityUtil().register(listener);
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
final long initialBackoffMs = BugleGservices.get().getLong(
BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS,
BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT);
final long maxDelayMs = BugleGservices.get().getLong(
BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS,
BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT);
long delayMs;
long nextDelayMs = initialBackoffMs;
do {
delayMs = nextDelayMs;
retryNumber--;
nextDelayMs = delayMs * 2;
}
while (retryNumber > 0 && nextDelayMs < maxDelayMs);
LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt
+ " in " + delayMs + " ms");
action.schedule(PENDING_INTENT_REQUEST_CODE, delayMs);
}
private static void unregister() {
// Clear any pending alarms or connectivity events
DataModel.get().getConnectivityUtil().unregister();
final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
action.schedule(PENDING_INTENT_REQUEST_CODE, Long.MAX_VALUE);
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed "
+ "events and clearing scheduled alarm");
}
}
private static void setRetry(final int retryAttempt) {
final BuglePrefs prefs = Factory.get().getApplicationPrefs();
prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
}
private static int getNextRetry() {
final BuglePrefs prefs = Factory.get().getApplicationPrefs();
final int retryAttempt =
prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1;
prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
return retryAttempt;
}
private ProcessPendingMessagesAction() {
}
/**
* Read from the DB and determine if there are any messages we should process
* @return true if we have pending messages
*/
private static boolean getHavePendingMessages() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final long now = System.currentTimeMillis();
final String toSendMessageId = findNextMessageToSend(db, now);
if (toSendMessageId != null) {
return true;
} else {
final String toDownloadMessageId = findNextMessageToDownload(db, now);
if (toDownloadMessageId != null) {
return true;
}
}
// Messages may be in the process of sending/downloading even when there are no pending
// messages...
return false;
}
/**
* Queue any pending actions
* @param actionState
* @return true if action queued (or no actions to queue) else false
*/
private boolean queueActions(final Action processingAction) {
final DatabaseWrapper db = DataModel.get().getDatabase();
final long now = System.currentTimeMillis();
boolean succeeded = true;
// Will queue no more than one message to send plus one message to download
// This keeps outgoing messages "in order" but allow downloads to happen even if sending
// gets blocked until messages time out. Manual resend bumps messages to head of queue.
final String toSendMessageId = findNextMessageToSend(db, now);
final String toDownloadMessageId = findNextMessageToDownload(db, now);
if (toSendMessageId != null) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId
+ " for sending");
// This could queue nothing
if (!SendMessageAction.queueForSendInBackground(toSendMessageId, processingAction)) {
LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
+ toSendMessageId + " for sending");
succeeded = false;
}
}
if (toDownloadMessageId != null) {
LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toDownloadMessageId
+ " for download");
// This could queue nothing
if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId,
processingAction)) {
LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
+ toDownloadMessageId + " for download");
succeeded = false;
}
}
if (toSendMessageId == null && toDownloadMessageId == null) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessPendingMessagesAction: No messages to send or download");
}
}
return succeeded;
}
@Override
protected Object executeAction() {
// If triggered by alarm will not have unregistered yet
unregister();
if (PhoneUtils.getDefault().isDefaultSmsApp()) {
queueActions(this);
} else {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling");
}
scheduleProcessPendingMessagesAction(true, this);
}
return null;
}
private static String findNextMessageToSend(final DatabaseWrapper db, final long now) {
String toSendMessageId = null;
db.beginTransaction();
Cursor sending = null;
Cursor cursor = null;
int sendingCnt = 0;
int pendingCnt = 0;
int failedCnt = 0;
try {
// First check to see if we have any messages already sending
sending = db.query(DatabaseHelper.MESSAGES_TABLE,
MessageData.getProjection(),
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
new String[]{Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)},
null,
null,
DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
final boolean messageCurrentlySending = sending.moveToNext();
sendingCnt = sending.getCount();
// Look for messages we could send
final ContentValues values = new ContentValues();
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_OUTGOING_FAILED);
cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
MessageData.getProjection(),
DatabaseHelper.MessageColumns.STATUS + " IN ("
+ MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ","
+ MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ")",
null,
null,
null,
DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
pendingCnt = cursor.getCount();
while (cursor.moveToNext()) {
final MessageData message = new MessageData();
message.bind(cursor);
if (message.getInResendWindow(now)) {
// If no messages currently sending
if (!messageCurrentlySending) {
// Resend this message
toSendMessageId = message.getMessageId();
// Before queuing the message for resending, check if the message's self is
// active. If not, switch back to the system's default subscription.
if (OsUtil.isAtLeastL_MR1()) {
final ParticipantData messageSelf = BugleDatabaseOperations
.getExistingParticipant(db, message.getSelfId());
if (messageSelf == null || !messageSelf.isActiveSubscription()) {
final ParticipantData defaultSelf = BugleDatabaseOperations
.getOrCreateSelf(db, PhoneUtils.getDefault()
.getDefaultSmsSubscriptionId());
if (defaultSelf != null) {
message.bindSelfId(defaultSelf.getId());
final ContentValues selfValues = new ContentValues();
selfValues.put(MessageColumns.SELF_PARTICIPANT_ID,
defaultSelf.getId());
BugleDatabaseOperations.updateMessageRow(db,
message.getMessageId(), selfValues);
MessagingContentProvider.notifyMessagesChanged(
message.getConversationId());
}
}
}
}
break;
} else {
failedCnt++;
// Mark message as failed
BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
if (cursor != null) {
cursor.close();
}
if (sending != null) {
sending.close();
}
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessPendingMessagesAction: "
+ sendingCnt + " messages already sending, "
+ pendingCnt + " messages to send, "
+ failedCnt + " failed messages");
}
return toSendMessageId;
}
private static String findNextMessageToDownload(final DatabaseWrapper db, final long now) {
String toDownloadMessageId = null;
db.beginTransaction();
Cursor cursor = null;
int downloadingCnt = 0;
int pendingCnt = 0;
try {
// First check if we have any messages already downloading
downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE,
DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)",
new String[] {
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING)
});
// TODO: This query is not actually needed if downloadingCnt == 0.
cursor = db.query(DatabaseHelper.MESSAGES_TABLE,
MessageData.getProjection(),
DatabaseHelper.MessageColumns.STATUS + " =? OR "
+ DatabaseHelper.MessageColumns.STATUS + " =?",
new String[]{
Integer.toString(
MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD),
Integer.toString(
MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD)
},
null,
null,
DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
pendingCnt = cursor.getCount();
// If no messages are currently downloading and there is a download pending,
// queue the download of the oldest pending message.
if (downloadingCnt == 0 && cursor.moveToNext()) {
// Always start the next pending message. We will check if a download has
// expired in DownloadMmsAction and mark message failed there.
final MessageData message = new MessageData();
message.bind(cursor);
toDownloadMessageId = message.getMessageId();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
if (cursor != null) {
cursor.close();
}
}
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ProcessPendingMessagesAction: "
+ downloadingCnt + " messages already downloading, "
+ pendingCnt + " messages to download");
}
return toDownloadMessageId;
}
private ProcessPendingMessagesAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessPendingMessagesAction> CREATOR
= new Parcelable.Creator<ProcessPendingMessagesAction>() {
@Override
public ProcessPendingMessagesAction createFromParcel(final Parcel in) {
return new ProcessPendingMessagesAction(in);
}
@Override
public ProcessPendingMessagesAction[] newArray(final int size) {
return new ProcessPendingMessagesAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,310 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.app.Activity;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.telephony.PhoneNumberUtils;
import android.telephony.SmsManager;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MmsFileProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.MessagePartData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.pdu.SendConf;
import com.android.messaging.sms.MmsConfig;
import com.android.messaging.sms.MmsSender;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.io.File;
import java.util.ArrayList;
/**
* Update message status to reflect success or failure
* Can also update the message itself if a "final" message is now available from telephony db
*/
public class ProcessSentMessageAction extends Action {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
// These are always set
private static final String KEY_SMS = "is_sms";
private static final String KEY_SENT_BY_PLATFORM = "sent_by_platform";
// These are set when we're processing a message sent by the user. They are null for messages
// sent automatically (e.g. a NotifyRespInd/AcknowledgeInd sent in response to a download).
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_MESSAGE_URI = "message_uri";
private static final String KEY_UPDATED_MESSAGE_URI = "updated_message_uri";
private static final String KEY_SUB_ID = "sub_id";
// These are set for messages sent by the platform (L+)
public static final String KEY_RESULT_CODE = "result_code";
public static final String KEY_HTTP_STATUS_CODE = "http_status_code";
private static final String KEY_CONTENT_URI = "content_uri";
private static final String KEY_RESPONSE = "response";
private static final String KEY_RESPONSE_IMPORTANT = "response_important";
// These are set for messages we sent ourself (legacy), or which we fast-failed before sending.
private static final String KEY_STATUS = "status";
private static final String KEY_RAW_STATUS = "raw_status";
// This is called when MMS lib API returns via PendingIntent
public static void processMmsSent(final int resultCode, final Uri messageUri,
final Bundle extras) {
final ProcessSentMessageAction action = new ProcessSentMessageAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_SMS, false);
params.putBoolean(KEY_SENT_BY_PLATFORM, true);
params.putString(KEY_MESSAGE_ID, extras.getString(SendMessageAction.EXTRA_MESSAGE_ID));
params.putParcelable(KEY_MESSAGE_URI, messageUri);
params.putParcelable(KEY_UPDATED_MESSAGE_URI,
extras.getParcelable(SendMessageAction.EXTRA_UPDATED_MESSAGE_URI));
params.putInt(KEY_SUB_ID,
extras.getInt(SendMessageAction.KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID));
params.putInt(KEY_RESULT_CODE, resultCode);
params.putInt(KEY_HTTP_STATUS_CODE, extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0));
params.putParcelable(KEY_CONTENT_URI,
extras.getParcelable(SendMessageAction.EXTRA_CONTENT_URI));
params.putByteArray(KEY_RESPONSE, extras.getByteArray(SmsManager.EXTRA_MMS_DATA));
params.putBoolean(KEY_RESPONSE_IMPORTANT,
extras.getBoolean(SendMessageAction.EXTRA_RESPONSE_IMPORTANT));
action.start();
}
public static void processMessageSentFastFailed(final String messageId,
final Uri messageUri, final Uri updatedMessageUri, final int subId, final boolean isSms,
final int status, final int rawStatus, final int resultCode) {
final ProcessSentMessageAction action = new ProcessSentMessageAction();
final Bundle params = action.actionParameters;
params.putBoolean(KEY_SMS, isSms);
params.putBoolean(KEY_SENT_BY_PLATFORM, false);
params.putString(KEY_MESSAGE_ID, messageId);
params.putParcelable(KEY_MESSAGE_URI, messageUri);
params.putParcelable(KEY_UPDATED_MESSAGE_URI, updatedMessageUri);
params.putInt(KEY_SUB_ID, subId);
params.putInt(KEY_STATUS, status);
params.putInt(KEY_RAW_STATUS, rawStatus);
params.putInt(KEY_RESULT_CODE, resultCode);
action.start();
}
private ProcessSentMessageAction() {
// Callers must use one of the static methods above
}
/**
* Update message status to reflect success or failure
* Can also update the message itself if a "final" message is now available from telephony db
*/
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI);
final Uri updatedMessageUri = actionParameters.getParcelable(KEY_UPDATED_MESSAGE_URI);
final boolean isSms = actionParameters.getBoolean(KEY_SMS);
final boolean sentByPlatform = actionParameters.getBoolean(KEY_SENT_BY_PLATFORM);
int status = actionParameters.getInt(KEY_STATUS, MmsUtils.MMS_REQUEST_MANUAL_RETRY);
int rawStatus = actionParameters.getInt(KEY_RAW_STATUS,
MmsUtils.PDU_HEADER_VALUE_UNDEFINED);
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
if (sentByPlatform) {
// Delete temporary file backing the contentUri passed to MMS service
final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI);
Assert.isTrue(contentUri != null);
final File tempFile = MmsFileProvider.getFile(contentUri);
long messageSize = 0;
if (tempFile.exists()) {
messageSize = tempFile.length();
tempFile.delete();
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "ProcessSentMessageAction: Deleted temp file with outgoing "
+ "MMS pdu: " + contentUri);
}
}
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
final boolean responseImportant = actionParameters.getBoolean(KEY_RESPONSE_IMPORTANT);
if (resultCode == Activity.RESULT_OK) {
if (responseImportant) {
// Get the status from the response PDU and update telephony
final byte[] response = actionParameters.getByteArray(KEY_RESPONSE);
final SendConf sendConf = MmsSender.parseSendConf(response, subId);
if (sendConf != null) {
final MmsUtils.StatusPlusUri result =
MmsUtils.updateSentMmsMessageStatus(context, messageUri, sendConf);
status = result.status;
rawStatus = result.rawStatus;
}
}
} else {
String errorMsg = "ProcessSentMessageAction: Platform returned error resultCode: "
+ resultCode;
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
if (httpStatusCode != 0) {
errorMsg += (", HTTP status code: " + httpStatusCode);
}
LogUtil.w(TAG, errorMsg);
status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode);
// Check for MMS messages that failed because they exceeded the maximum size,
// indicated by an I/O error from the platform.
if (resultCode == SmsManager.MMS_ERROR_IO_ERROR) {
if (messageSize > MmsConfig.get(subId).getMaxMessageSize()) {
rawStatus = MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG;
}
}
}
}
if (messageId != null) {
final int resultCode = actionParameters.getInt(KEY_RESULT_CODE);
final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE);
processResult(
messageId, updatedMessageUri, status, rawStatus, isSms, this, subId,
resultCode, httpStatusCode);
} else {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "ProcessSentMessageAction: No sent message to process (it was "
+ "probably a notify response for an MMS download)");
}
}
return null;
}
static void processResult(final String messageId, Uri updatedMessageUri, int status,
final int rawStatus, final boolean isSms, final Action processingAction,
final int subId, final int resultCode, final int httpStatusCode) {
final DatabaseWrapper db = DataModel.get().getDatabase();
MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
final MessageData originalMessage = message;
if (message == null) {
LogUtil.w(TAG, "ProcessSentMessageAction: Sent message " + messageId
+ " missing from local database");
return;
}
final String conversationId = message.getConversationId();
if (updatedMessageUri != null) {
// Update message if we have newly written final message in the telephony db
final MessageData update = MmsUtils.readSendingMmsMessage(updatedMessageUri,
conversationId, message.getParticipantId(), message.getSelfId());
if (update != null) {
// Set message Id of final message to that of the existing place holder.
update.updateMessageId(message.getMessageId());
// Update image sizes.
update.updateSizesForImageParts();
// Temp attachments are no longer needed
for (final MessagePartData part : message.getParts()) {
part.destroySync();
}
message = update;
// processResult will rewrite the complete message as part of update
} else {
updatedMessageUri = null;
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
LogUtil.e(TAG, "ProcessSentMessageAction: Unable to read sending message");
}
}
final long timestamp = System.currentTimeMillis();
boolean failed;
if (status == MmsUtils.MMS_REQUEST_SUCCEEDED) {
message.markMessageSent(timestamp);
failed = false;
} else if (status == MmsUtils.MMS_REQUEST_AUTO_RETRY
&& message.getInResendWindow(timestamp)) {
message.markMessageNotSent(timestamp);
message.setRawTelephonyStatus(rawStatus);
failed = false;
} else {
message.markMessageFailed(timestamp);
message.setRawTelephonyStatus(rawStatus);
message.setMessageSeen(false);
failed = true;
}
// We have special handling for when a message to an emergency number fails. In this case,
// we notify immediately of any failure (even if we auto-retry), and instruct the user to
// try calling the emergency number instead.
if (status != MmsUtils.MMS_REQUEST_SUCCEEDED) {
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
for (final String recipient : recipients) {
if (PhoneNumberUtils.isEmergencyNumber(recipient)) {
BugleNotifications.notifyEmergencySmsFailed(recipient, conversationId);
message.markMessageFailedEmergencyNumber(timestamp);
failed = true;
break;
}
}
}
// Update the message status and optionally refresh the message with final parts/values.
if (SendMessageAction.updateMessageAndStatus(isSms, message, updatedMessageUri, failed)) {
// We shouldn't show any notifications if we're not allowed to modify Telephony for
// this message.
if (failed) {
BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS);
}
BugleActionToasts.onSendMessageOrManualDownloadActionCompleted(
conversationId, !failed, status, isSms, subId, true/*isSend*/);
}
LogUtil.i(TAG, "ProcessSentMessageAction: Done sending " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId()
+ " in conversation " + conversationId
+ "; status is " + MmsUtils.getRequestStatusDescription(status));
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(
status != MmsUtils.MMS_REQUEST_SUCCEEDED, processingAction);
}
private ProcessSentMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ProcessSentMessageAction> CREATOR
= new Parcelable.Creator<ProcessSentMessageAction>() {
@Override
public ProcessSentMessageAction createFromParcel(final Parcel in) {
return new ProcessSentMessageAction(in);
}
@Override
public ProcessSentMessageAction[] newArray(final int size) {
return new ProcessSentMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,166 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.Assert;
import com.android.messaging.util.Assert.RunsOnMainThread;
import com.android.messaging.util.LogUtil;
import com.google.common.annotations.VisibleForTesting;
public class ReadDraftDataAction extends Action implements Parcelable {
/**
* Interface for ReadDraftDataAction listeners
*/
public interface ReadDraftDataActionListener {
@RunsOnMainThread
abstract void onReadDraftDataSucceeded(final ReadDraftDataAction action,
final Object data, final MessageData message,
final ConversationListItemData conversation);
@RunsOnMainThread
abstract void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data);
}
/**
* Read draft message and associated data (with listener)
*/
public static ReadDraftDataActionMonitor readDraftData(final String conversationId,
final MessageData incomingDraft, final Object data,
final ReadDraftDataActionListener listener) {
final ReadDraftDataActionMonitor monitor = new ReadDraftDataActionMonitor(data,
listener);
final ReadDraftDataAction action = new ReadDraftDataAction(conversationId,
incomingDraft, monitor.getActionKey());
action.start(monitor);
return monitor;
}
private static final String KEY_CONVERSATION_ID = "conversationId";
private static final String KEY_INCOMING_DRAFT = "draftMessage";
private ReadDraftDataAction(final String conversationId, final MessageData incomingDraft,
final String actionKey) {
super(actionKey);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putParcelable(KEY_INCOMING_DRAFT, incomingDraft);
}
@VisibleForTesting
class DraftData {
public final MessageData message;
public final ConversationListItemData conversation;
DraftData(final MessageData message, final ConversationListItemData conversation) {
this.message = message;
this.conversation = conversation;
}
}
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final MessageData incomingDraft = actionParameters.getParcelable(KEY_INCOMING_DRAFT);
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
MessageData message = null;
if (conversation != null) {
if (incomingDraft == null) {
message = BugleDatabaseOperations.readDraftMessageData(db, conversationId,
conversation.getSelfId());
}
if (message == null) {
message = MessageData.createDraftMessage(conversationId, conversation.getSelfId(),
incomingDraft);
LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: created draft. "
+ "conversationId=" + conversationId
+ " selfId=" + conversation.getSelfId());
} else {
LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: read draft. "
+ "conversationId=" + conversationId
+ " selfId=" + conversation.getSelfId());
}
return new DraftData(message, conversation);
}
return null;
}
/**
* An operation that notifies a listener upon completion
*/
public static class ReadDraftDataActionMonitor extends ActionMonitor
implements ActionCompletedListener {
private final ReadDraftDataActionListener mListener;
ReadDraftDataActionMonitor(final Object data,
final ReadDraftDataActionListener completed) {
super(STATE_CREATED, generateUniqueActionKey("ReadDraftDataAction"), data);
setCompletedListener(this);
mListener = completed;
}
@Override
public void onActionSucceeded(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
final DraftData draft = (DraftData) result;
if (draft == null) {
mListener.onReadDraftDataFailed((ReadDraftDataAction) action, data);
} else {
mListener.onReadDraftDataSucceeded((ReadDraftDataAction) action, data,
draft.message, draft.conversation);
}
}
@Override
public void onActionFailed(final ActionMonitor monitor,
final Action action, final Object data, final Object result) {
Assert.fail("Reading draft should not fail");
}
}
private ReadDraftDataAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ReadDraftDataAction> CREATOR
= new Parcelable.Creator<ReadDraftDataAction>() {
@Override
public ReadDraftDataAction createFromParcel(final Parcel in) {
return new ReadDraftDataAction(in);
}
@Override
public ReadDraftDataAction[] newArray(final int size) {
return new ReadDraftDataAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,197 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DataModelException;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.pdu.PduHeaders;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.LogUtil;
import java.util.List;
/**
* Action used to "receive" an incoming message
*/
public class ReceiveMmsMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_SUB_ID = "sub_id";
private static final String KEY_PUSH_DATA = "push_data";
private static final String KEY_TRANSACTION_ID = "transaction_id";
private static final String KEY_CONTENT_LOCATION = "content_location";
/**
* Create a message received from a particular number in a particular conversation
*/
public ReceiveMmsMessageAction(final int subId, final byte[] pushData) {
actionParameters.putInt(KEY_SUB_ID, subId);
actionParameters.putByteArray(KEY_PUSH_DATA, pushData);
}
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final byte[] pushData = actionParameters.getByteArray(KEY_PUSH_DATA);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Write received message to telephony DB
MessageData message = null;
final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId);
final long received = System.currentTimeMillis();
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(received);
// TODO: Should use local time to set received time in MMS message
final DatabaseMessages.MmsMessage mms = MmsUtils.processReceivedPdu(
context, pushData, self.getSubId(), self.getNormalizedDestination());
if (mms != null) {
final List<String> recipients = MmsUtils.getRecipientsByThread(mms.mThreadId);
String from = MmsUtils.getMmsSender(recipients, mms.getUri());
if (from == null) {
LogUtil.w(TAG, "Received an MMS without sender address; using unknown sender.");
from = ParticipantData.getUnknownSenderDestination();
}
final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale(
from, subId);
final boolean blocked = BugleDatabaseOperations.isBlockedDestination(
db, rawSender.getNormalizedDestination());
final boolean autoDownload = (!blocked && MmsUtils.allowMmsAutoRetrieve(subId));
final String conversationId =
BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, mms.mThreadId,
blocked, subId);
final boolean messageInFocusedConversation =
DataModel.get().isFocusedConversation(conversationId);
final boolean messageInObservableConversation =
DataModel.get().isNewMessageObservable(conversationId);
// TODO: Also write these values to the telephony provider
mms.mRead = messageInFocusedConversation;
mms.mSeen = messageInObservableConversation || blocked;
// Write received placeholder message to our DB
db.beginTransaction();
try {
final String participantId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender);
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId,
(autoDownload ? MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD :
MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD));
// Write the message
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
if (!autoDownload) {
BugleDatabaseOperations.updateConversationMetadataInTransaction(db,
conversationId, message.getMessageId(), message.getReceivedTimeStamp(),
blocked, true /* shouldAutoSwitchSelfId */);
final ParticipantData sender = ParticipantData .getFromId(
db, participantId);
BugleActionToasts.onMessageReceived(conversationId, sender, message);
}
// else update the conversation once we have downloaded final message (or failed)
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// Update conversation if not immediately initiating a download
if (!autoDownload) {
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
MessagingContentProvider.notifyPartsChanged();
// Show a notification to let the user know a new message has arrived
BugleNotifications.update(false/*silent*/, conversationId,
BugleNotifications.UPDATE_ALL);
// Send the NotifyRespInd with DEFERRED status since no auto download
actionParameters.putString(KEY_TRANSACTION_ID, mms.mTransactionId);
actionParameters.putString(KEY_CONTENT_LOCATION, mms.mContentLocation);
requestBackgroundWork();
}
LogUtil.i(TAG, "ReceiveMmsMessageAction: Received MMS message " + message.getMessageId()
+ " in conversation " + message.getConversationId()
+ ", uri = " + message.getSmsMessageUri());
} else {
LogUtil.e(TAG, "ReceiveMmsMessageAction: Skipping processing of incoming PDU");
}
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
}
@Override
protected Bundle doBackgroundWork() throws DataModelException {
final Context context = Factory.get().getApplicationContext();
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID);
final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION);
MmsUtils.sendNotifyResponseForMmsDownload(
context,
subId,
MmsUtils.stringToBytes(transactionId, "UTF-8"),
contentLocation,
PduHeaders.STATUS_DEFERRED);
// We don't need to return anything.
return null;
}
private ReceiveMmsMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ReceiveMmsMessageAction> CREATOR
= new Parcelable.Creator<ReceiveMmsMessageAction>() {
@Override
public ReceiveMmsMessageAction createFromParcel(final Parcel in) {
return new ReceiveMmsMessageAction(in);
}
@Override
public ReceiveMmsMessageAction[] newArray(final int size) {
return new ReceiveMmsMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,198 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Sms;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsSmsUtils;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
/**
* Action used to "receive" an incoming message
*/
public class ReceiveSmsMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_MESSAGE_VALUES = "message_values";
/**
* Create a message received from a particular number in a particular conversation
*/
public ReceiveSmsMessageAction(final ContentValues messageValues) {
actionParameters.putParcelable(KEY_MESSAGE_VALUES, messageValues);
}
@Override
protected Object executeAction() {
final Context context = Factory.get().getApplicationContext();
final ContentValues messageValues = actionParameters.getParcelable(KEY_MESSAGE_VALUES);
final DatabaseWrapper db = DataModel.get().getDatabase();
// Get the SIM subscription ID
Integer subId = messageValues.getAsInteger(Sms.SUBSCRIPTION_ID);
if (subId == null) {
subId = ParticipantData.DEFAULT_SELF_SUB_ID;
}
// Make sure we have a sender address
String address = messageValues.getAsString(Sms.ADDRESS);
if (TextUtils.isEmpty(address)) {
LogUtil.w(TAG, "Received an SMS without an address; using unknown sender.");
address = ParticipantData.getUnknownSenderDestination();
messageValues.put(Sms.ADDRESS, address);
}
final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale(
address, subId);
// TODO: Should use local timestamp for this?
final long received = messageValues.getAsLong(Sms.DATE);
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(received);
// Make sure we've got a thread id
final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(context, address);
messageValues.put(Sms.THREAD_ID, threadId);
final boolean blocked = BugleDatabaseOperations.isBlockedDestination(
db, rawSender.getNormalizedDestination());
final String conversationId = BugleDatabaseOperations.
getOrCreateConversationFromRecipient(db, threadId, blocked, rawSender);
final boolean messageInFocusedConversation =
DataModel.get().isFocusedConversation(conversationId);
final boolean messageInObservableConversation =
DataModel.get().isNewMessageObservable(conversationId);
MessageData message = null;
// Only the primary user gets to insert the message into the telephony db and into bugle's
// db. The secondary user goes through this path, but skips doing the actual insert. It
// goes through this path because it needs to compute messageInFocusedConversation in order
// to calculate whether to skip the notification and play a soft sound if the user is
// already in the conversation.
if (!OsUtil.isSecondaryUser()) {
final boolean read = messageValues.getAsBoolean(Sms.Inbox.READ)
|| messageInFocusedConversation;
// If you have read it you have seen it
final boolean seen = read || messageInObservableConversation || blocked;
messageValues.put(Sms.Inbox.READ, read ? Integer.valueOf(1) : Integer.valueOf(0));
// incoming messages are marked as seen in the telephony db
messageValues.put(Sms.Inbox.SEEN, 1);
// Insert into telephony
final Uri messageUri = context.getContentResolver().insert(Sms.Inbox.CONTENT_URI,
messageValues);
if (messageUri != null) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ReceiveSmsMessageAction: Inserted SMS message into telephony, "
+ "uri = " + messageUri);
}
} else {
LogUtil.e(TAG, "ReceiveSmsMessageAction: Failed to insert SMS into telephony!");
}
final String text = messageValues.getAsString(Sms.BODY);
final String subject = messageValues.getAsString(Sms.SUBJECT);
final long sent = messageValues.getAsLong(Sms.DATE_SENT);
final ParticipantData self = ParticipantData.getSelfParticipant(subId);
final Integer pathPresent = messageValues.getAsInteger(Sms.REPLY_PATH_PRESENT);
final String smsServiceCenter = messageValues.getAsString(Sms.SERVICE_CENTER);
String conversationServiceCenter = null;
// Only set service center if message REPLY_PATH_PRESENT = 1
if (pathPresent != null && pathPresent == 1 && !TextUtils.isEmpty(smsServiceCenter)) {
conversationServiceCenter = smsServiceCenter;
}
db.beginTransaction();
try {
final String participantId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender);
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
message = MessageData.createReceivedSmsMessage(messageUri, conversationId,
participantId, selfId, text, subject, sent, received, seen, read);
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
BugleDatabaseOperations.updateConversationMetadataInTransaction(db, conversationId,
message.getMessageId(), message.getReceivedTimeStamp(), blocked,
conversationServiceCenter, true /* shouldAutoSwitchSelfId */);
final ParticipantData sender = ParticipantData.getFromId(db, participantId);
BugleActionToasts.onMessageReceived(conversationId, sender, message);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
LogUtil.i(TAG, "ReceiveSmsMessageAction: Received SMS message " + message.getMessageId()
+ " in conversation " + message.getConversationId()
+ ", uri = " + messageUri);
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
} else {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "ReceiveSmsMessageAction: Not inserting received SMS message for "
+ "secondary user.");
}
}
// Show a notification to let the user know a new message has arrived
BugleNotifications.update(false/*silent*/, conversationId, BugleNotifications.UPDATE_ALL);
MessagingContentProvider.notifyMessagesChanged(conversationId);
MessagingContentProvider.notifyPartsChanged();
return message;
}
private ReceiveSmsMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ReceiveSmsMessageAction> CREATOR
= new Parcelable.Creator<ReceiveSmsMessageAction>() {
@Override
public ReceiveSmsMessageAction createFromParcel(final Parcel in) {
return new ReceiveSmsMessageAction(in);
}
@Override
public ReceiveSmsMessageAction[] newArray(final int size) {
return new ReceiveSmsMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.app.PendingIntent;
import android.content.ContentValues;
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.BugleNotifications;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
/**
* Action to manually start an MMS download (after failed or manual mms download)
*/
public class RedownloadMmsAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final int REQUEST_CODE_PENDING_INTENT = 102;
/**
* Download an MMS message
*/
public static void redownloadMessage(final String messageId) {
final RedownloadMmsAction action = new RedownloadMmsAction(messageId);
action.start();
}
/**
* Get a pending intent of for downloading an MMS
*/
public static PendingIntent getPendingIntentForRedownloadMms(
final Context context, final String messageId) {
final Action action = new RedownloadMmsAction(messageId);
return ActionService.makeStartActionPendingIntent(context,
action, REQUEST_CODE_PENDING_INTENT, false /*launchesAnActivity*/);
}
// Core parameters needed for all types of message
private static final String KEY_MESSAGE_ID = "message_id";
/**
* Constructor used for retrying sending in the background (only message id available)
*/
RedownloadMmsAction(final String messageId) {
super();
actionParameters.putString(KEY_MESSAGE_ID, messageId);
}
/**
* Read message from database and change status to allow downloading
*/
@Override
protected Object executeAction() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be redownloaded
if (message != null && message.canRedownloadMessage()) {
final long timestamp = System.currentTimeMillis();
final ContentValues values = new ContentValues(2);
values.put(DatabaseHelper.MessageColumns.STATUS,
MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD);
values.put(DatabaseHelper.MessageColumns.RETRY_START_TIMESTAMP, timestamp);
// Row must exist as was just loaded above (on ActionService thread)
BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
} else {
message = null;
LogUtil.e(LogUtil.BUGLE_TAG,
"Attempt to download a missing or un-redownloadable message");
}
// Immediately update the notifications in case we came from the download action from a
// heads-up notification. This will dismiss the heads-up notification.
BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL);
return message;
}
private RedownloadMmsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<RedownloadMmsAction> CREATOR
= new Parcelable.Creator<RedownloadMmsAction>() {
@Override
public RedownloadMmsAction createFromParcel(final Parcel in) {
return new RedownloadMmsAction(in);
}
@Override
public RedownloadMmsAction[] newArray(final int size) {
return new RedownloadMmsAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
/**
* Action used to manually resend an outgoing message
*/
public class ResendMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Manual send of existing message (no listener)
*/
public static void resendMessage(final String messageId) {
final ResendMessageAction action = new ResendMessageAction(messageId);
action.start();
}
// Core parameters needed for all types of message
private static final String KEY_MESSAGE_ID = "message_id";
/**
* Constructor used for retrying sending in the background (only message id available)
*/
ResendMessageAction(final String messageId) {
super();
actionParameters.putString(KEY_MESSAGE_ID, messageId);
}
/**
* Read message from database and change status to allow sending
*/
@Override
protected Object executeAction() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be resent
if (message != null && message.canResendMessage()) {
final boolean isMms = message.getIsMms();
long timestamp = System.currentTimeMillis();
if (isMms) {
// MMS expects timestamp rounded to nearest second
timestamp = 1000 * ((timestamp + 500) / 1000);
}
LogUtil.i(TAG, "ResendMessageAction: Resending message " + messageId
+ "; changed timestamp from " + message.getReceivedTimeStamp() + " to "
+ timestamp);
final ContentValues values = new ContentValues();
values.put(MessageColumns.STATUS, MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND);
values.put(MessageColumns.RECEIVED_TIMESTAMP, timestamp);
values.put(MessageColumns.SENT_TIMESTAMP, timestamp);
values.put(MessageColumns.RETRY_START_TIMESTAMP, timestamp);
// Row must exist as was just loaded above (on ActionService thread)
BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this);
return message;
} else {
String error = "ResendMessageAction: Cannot resend message " + messageId + "; ";
if (message != null) {
error += ("status = " + MessageData.getStatusDescription(message.getStatus()));
} else {
error += "not found in database";
}
LogUtil.e(TAG, error);
}
return null;
}
private ResendMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<ResendMessageAction> CREATOR
= new Parcelable.Creator<ResendMessageAction>() {
@Override
public ResendMessageAction createFromParcel(final Parcel in) {
return new ResendMessageAction(in);
}
@Override
public ResendMessageAction[] newArray(final int size) {
return new ResendMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,447 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Sms;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
/**
* Action used to send an outgoing message. It writes MMS messages to the telephony db
* ({@link InsertNewMessageAction}) writes SMS messages to the telephony db). It also
* initiates the actual sending. It will all be used for re-sending a failed message.
* NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure).
* <p>
* This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to
* access the EXTRA_* fields for setting up the 'sent' pending intent.
*/
public class SendMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Queue sending of existing message (can only be called during execute of action)
*/
static boolean queueForSendInBackground(final String messageId,
final Action processingAction) {
final SendMessageAction action = new SendMessageAction();
return action.queueAction(messageId, processingAction);
}
public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false;
public static final int MAX_SMS_RETRY = 3;
// Core parameters needed for all types of message
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_MESSAGE = "message";
private static final String KEY_MESSAGE_URI = "message_uri";
private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number";
// For sms messages a few extra values are included in the bundle
private static final String KEY_RECIPIENT = "recipient";
private static final String KEY_RECIPIENTS = "recipients";
private static final String KEY_SMS_SERVICE_CENTER = "sms_service_center";
// Values we attach to the pending intent that's fired when the message is sent.
// Only applicable when sending via the platform APIs on L+.
public static final String KEY_SUB_ID = "sub_id";
public static final String EXTRA_MESSAGE_ID = "message_id";
public static final String EXTRA_UPDATED_MESSAGE_URI = "updated_message_uri";
public static final String EXTRA_CONTENT_URI = "content_uri";
public static final String EXTRA_RESPONSE_IMPORTANT = "response_important";
/**
* Constructor used for retrying sending in the background (only message id available)
*/
private SendMessageAction() {
super();
}
/**
* Read message from database and queue actual sending
*/
private boolean queueAction(final String messageId, final Action processingAction) {
actionParameters.putString(KEY_MESSAGE_ID, messageId);
final long timestamp = System.currentTimeMillis();
final DatabaseWrapper db = DataModel.get().getDatabase();
final MessageData message = BugleDatabaseOperations.readMessage(db, messageId);
// Check message can be resent
if (message != null && message.canSendMessage()) {
final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS);
final ParticipantData self = BugleDatabaseOperations.getExistingParticipant(
db, message.getSelfId());
final Uri messageUri = message.getSmsMessageUri();
final String conversationId = message.getConversationId();
// Update message status
if (message.getYetToSend()) {
// Initial sending of message
message.markMessageSending(timestamp);
} else {
// Automatic resend of message
message.markMessageResending(timestamp);
}
if (!updateMessageAndStatus(isSms, message, null /* messageUri */, false /*notify*/)) {
// If message is missing in the telephony database we don't need to send it
return false;
}
final ArrayList<String> recipients =
BugleDatabaseOperations.getRecipientsForConversation(db, conversationId);
// Update action state with parameters needed for background sending
actionParameters.putParcelable(KEY_MESSAGE_URI, messageUri);
actionParameters.putParcelable(KEY_MESSAGE, message);
actionParameters.putStringArrayList(KEY_RECIPIENTS, recipients);
actionParameters.putInt(KEY_SUB_ID, self.getSubId());
actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination());
if (isSms) {
final String smsc = BugleDatabaseOperations.getSmsServiceCenterForConversation(
db, conversationId);
actionParameters.putString(KEY_SMS_SERVICE_CENTER, smsc);
if (recipients.size() == 1) {
final String recipient = recipients.get(0);
actionParameters.putString(KEY_RECIPIENT, recipient);
// Queue actual sending for SMS
processingAction.requestBackgroundWork(this);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SendMessageAction: Queued SMS message " + messageId
+ " for sending");
}
return true;
} else {
LogUtil.wtf(TAG, "Trying to resend a broadcast SMS - not allowed");
}
} else {
// Queue actual sending for MMS
processingAction.requestBackgroundWork(this);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SendMessageAction: Queued MMS message " + messageId
+ " for sending");
}
return true;
}
}
return false;
}
/**
* Never called
*/
@Override
protected Object executeAction() {
Assert.fail("SendMessageAction must be queued rather than started");
return null;
}
/**
* Send message on background worker thread
*/
@Override
protected Bundle doBackgroundWork() {
final MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI);
Uri updatedMessageUri = null;
final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS;
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER);
LogUtil.i(TAG, "SendMessageAction: Sending " + (isSms ? "SMS" : "MMS") + " message "
+ messageId + " in conversation " + message.getConversationId());
int status;
int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED;
int resultCode = MessageData.UNKNOWN_RESULT_CODE;
if (isSms) {
Assert.notNull(messageUri);
final String recipient = actionParameters.getString(KEY_RECIPIENT);
final String messageText = message.getMessageText();
final String smsServiceCenter = actionParameters.getString(KEY_SMS_SERVICE_CENTER);
final boolean deliveryReportRequired = MmsUtils.isDeliveryReportRequired(subId);
status = MmsUtils.sendSmsMessage(recipient, messageText, messageUri, subId,
smsServiceCenter, deliveryReportRequired);
} else {
final Context context = Factory.get().getApplicationContext();
final ArrayList<String> recipients =
actionParameters.getStringArrayList(KEY_RECIPIENTS);
if (messageUri == null) {
final long timestamp = message.getReceivedTimeStamp();
// Inform sync that message has been added at local received timestamp
final SyncManager syncManager = DataModel.get().getSyncManager();
syncManager.onNewMessageInserted(timestamp);
// For MMS messages first need to write to telephony (resizing images if needed)
updatedMessageUri = MmsUtils.insertSendingMmsMessage(context, recipients,
message, subId, subPhoneNumber, timestamp);
if (updatedMessageUri != null) {
messageUri = updatedMessageUri;
// To prevent Sync seeing inconsistent state must write to DB on this thread
updateMessageUri(messageId, updatedMessageUri);
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SendMessageAction: Updated message " + messageId
+ " with new uri " + messageUri);
}
}
}
if (messageUri != null) {
// Actually send the MMS
final Bundle extras = new Bundle();
extras.putString(EXTRA_MESSAGE_ID, messageId);
extras.putParcelable(EXTRA_UPDATED_MESSAGE_URI, updatedMessageUri);
final MmsUtils.StatusPlusUri result = MmsUtils.sendMmsMessage(context, subId,
messageUri, extras);
if (result == MmsUtils.STATUS_PENDING) {
// Async send, so no status yet
LogUtil.d(TAG, "SendMessageAction: Sending MMS message " + messageId
+ " asynchronously; waiting for callback to finish processing");
return null;
}
status = result.status;
rawStatus = result.rawStatus;
resultCode = result.resultCode;
} else {
status = MmsUtils.MMS_REQUEST_MANUAL_RETRY;
}
}
// When we fast-fail before calling the MMS lib APIs (e.g. airplane mode,
// sending message is deleted).
ProcessSentMessageAction.processMessageSentFastFailed(messageId, messageUri,
updatedMessageUri, subId, isSms, status, rawStatus, resultCode);
return null;
}
private void updateMessageUri(final String messageId, final Uri updatedMessageUri) {
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(MessageColumns.SMS_MESSAGE_URI, updatedMessageUri.toString());
BugleDatabaseOperations.updateMessageRow(db, messageId, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Override
protected Object processBackgroundResponse(final Bundle response) {
// Nothing to do here, post-send tasks handled by ProcessSentMessageAction
return null;
}
/**
* Update message status to reflect success or failure
*/
@Override
protected Object processBackgroundFailure() {
final String messageId = actionParameters.getString(KEY_MESSAGE_ID);
final MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS;
final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID);
final int resultCode = actionParameters.getInt(ProcessSentMessageAction.KEY_RESULT_CODE);
final int httpStatusCode =
actionParameters.getInt(ProcessSentMessageAction.KEY_HTTP_STATUS_CODE);
ProcessSentMessageAction.processResult(messageId, null /* updatedMessageUri */,
MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED,
isSms, this, subId, resultCode, httpStatusCode);
// Whether we succeeded or failed we will check and maybe schedule some more work
ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true, this);
return null;
}
/**
* Update the message status (and message itself if necessary)
* @param isSms whether this is an SMS or MMS
* @param message message to update
* @param updatedMessageUri message uri for newly-inserted messages; null otherwise
* @param clearSeen whether the message 'seen' status should be reset if error occurs
*/
public static boolean updateMessageAndStatus(final boolean isSms, final MessageData message,
final Uri updatedMessageUri, final boolean clearSeen) {
final Context context = Factory.get().getApplicationContext();
final DatabaseWrapper db = DataModel.get().getDatabase();
// TODO: We're optimistically setting the type/box of outgoing messages to
// 'SENT' even before they actually are. We should technically be using QUEUED or OUTBOX
// instead, but if we do that, it's possible that the Messaging app will try to send them
// as part of its clean-up logic that runs when it starts (http://b/18155366).
//
// We also use the wrong status when inserting queued SMS messages in
// InsertNewMessageAction.insertBroadcastSmsMessage and insertSendingSmsMessage (should be
// QUEUED or OUTBOX), and in MmsUtils.insertSendReq (should be OUTBOX).
boolean updatedTelephony = true;
int messageBox;
int type;
switch(message.getStatus()) {
case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED:
type = Sms.MESSAGE_TYPE_SENT;
messageBox = Mms.MESSAGE_BOX_SENT;
break;
case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
type = Sms.MESSAGE_TYPE_SENT;
messageBox = Mms.MESSAGE_BOX_SENT;
break;
case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
type = Sms.MESSAGE_TYPE_SENT;
messageBox = Mms.MESSAGE_BOX_SENT;
break;
case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
type = Sms.MESSAGE_TYPE_FAILED;
messageBox = Mms.MESSAGE_BOX_FAILED;
break;
default:
type = Sms.MESSAGE_TYPE_ALL;
messageBox = Mms.MESSAGE_BOX_ALL;
break;
}
// First in the telephony DB
if (isSms) {
// Ignore update message Uri
if (type != Sms.MESSAGE_TYPE_ALL) {
if (!MmsUtils.updateSmsMessageSendingStatus(context, message.getSmsMessageUri(),
type, message.getReceivedTimeStamp())) {
message.markMessageFailed(message.getSentTimeStamp());
updatedTelephony = false;
}
}
} else if (message.getSmsMessageUri() != null) {
if (messageBox != Mms.MESSAGE_BOX_ALL) {
if (!MmsUtils.updateMmsMessageSendingStatus(context, message.getSmsMessageUri(),
messageBox, message.getReceivedTimeStamp())) {
message.markMessageFailed(message.getSentTimeStamp());
updatedTelephony = false;
}
}
}
if (updatedTelephony) {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId()
+ " in telephony (" + message.getSmsMessageUri() + ")");
}
} else {
LogUtil.w(TAG, "SendMessageAction: Failed to update " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId()
+ " in telephony (" + message.getSmsMessageUri() + "); marking message failed");
}
// Update the local DB
db.beginTransaction();
try {
if (updatedMessageUri != null) {
// Update all message and part fields
BugleDatabaseOperations.updateMessageInTransaction(db, message);
BugleDatabaseOperations.refreshConversationMetadataInTransaction(
db, message.getConversationId(), false/* shouldAutoSwitchSelfId */,
false/*archived*/);
} else {
final ContentValues values = new ContentValues();
values.put(MessageColumns.STATUS, message.getStatus());
if (clearSeen) {
// When a message fails to send, the message needs to
// be unseen to be selected as an error notification.
values.put(MessageColumns.SEEN, 0);
}
values.put(MessageColumns.RECEIVED_TIMESTAMP, message.getReceivedTimeStamp());
values.put(MessageColumns.RAW_TELEPHONY_STATUS, message.getRawTelephonyStatus());
BugleDatabaseOperations.updateMessageRowIfExists(db, message.getMessageId(),
values);
}
db.setTransactionSuccessful();
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS")
+ " message " + message.getMessageId() + " in local db. Timestamp = "
+ message.getReceivedTimeStamp());
}
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
if (updatedMessageUri != null) {
MessagingContentProvider.notifyPartsChanged();
}
return updatedTelephony;
}
private SendMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<SendMessageAction> CREATOR
= new Parcelable.Creator<SendMessageAction>() {
@Override
public SendMessageAction createFromParcel(final Parcel in) {
return new SendMessageAction(in);
}
@Override
public SendMessageAction[] newArray(final int size) {
return new SendMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,712 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Sms;
import android.support.v4.util.LongSparseArray;
import android.text.TextUtils;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.DatabaseMessages.DatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.MmsMessage;
import com.android.messaging.sms.DatabaseMessages.SmsMessage;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import com.google.common.collect.Sets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Class holding a pair of cursors - one for local db and one for telephony provider - allowing
* synchronous stepping through messages as part of sync.
*/
class SyncCursorPair {
private static final String TAG = LogUtil.BUGLE_TAG;
static final long SYNC_COMPLETE = -1L;
static final long SYNC_STARTING = Long.MAX_VALUE;
private CursorIterator mLocalCursorIterator;
private CursorIterator mRemoteCursorsIterator;
private final String mLocalSelection;
private final String mRemoteSmsSelection;
private final String mRemoteMmsSelection;
/**
* Check if SMS has been synchronized. We compare the counts of messages on both
* sides and return true if they are equal.
*
* Note that this may not be the most reliable way to tell if messages are in sync.
* For example, the local misses one message and has one obsolete message.
* However, we have background sms sync once a while, also some other events might
* trigger a full sync. So we will eventually catch up. And this should be rare to
* happen.
*
* @return If sms is in sync with telephony sms/mms providers
*/
static boolean allSynchronized(final DatabaseWrapper db) {
return isSynchronized(db, LOCAL_MESSAGES_SELECTION, null,
getSmsTypeSelectionSql(), null, getMmsTypeSelectionSql(), null);
}
SyncCursorPair(final long lowerBound, final long upperBound) {
mLocalSelection = getTimeConstrainedQuery(
LOCAL_MESSAGES_SELECTION,
MessageColumns.RECEIVED_TIMESTAMP,
lowerBound,
upperBound,
null /* threadColumn */, null /* threadId */);
mRemoteSmsSelection = getTimeConstrainedQuery(
getSmsTypeSelectionSql(),
"date",
lowerBound,
upperBound,
null /* threadColumn */, null /* threadId */);
mRemoteMmsSelection = getTimeConstrainedQuery(
getMmsTypeSelectionSql(),
"date",
((lowerBound < 0) ? lowerBound : (lowerBound + 999) / 1000), /*seconds*/
((upperBound < 0) ? upperBound : (upperBound + 999) / 1000), /*seconds*/
null /* threadColumn */, null /* threadId */);
}
SyncCursorPair(final long threadId, final String conversationId) {
mLocalSelection = getTimeConstrainedQuery(
LOCAL_MESSAGES_SELECTION,
MessageColumns.RECEIVED_TIMESTAMP,
-1L,
-1L,
MessageColumns.CONVERSATION_ID, conversationId);
// Find all SMS messages (excluding drafts) within the sync window
mRemoteSmsSelection = getTimeConstrainedQuery(
getSmsTypeSelectionSql(),
"date",
-1L,
-1L,
Sms.THREAD_ID, Long.toString(threadId));
mRemoteMmsSelection = getTimeConstrainedQuery(
getMmsTypeSelectionSql(),
"date",
-1L, /*seconds*/
-1L, /*seconds*/
Mms.THREAD_ID, Long.toString(threadId));
}
void query(final DatabaseWrapper db) {
// Load local messages in the sync window
mLocalCursorIterator = new LocalCursorIterator(db, mLocalSelection);
// Load remote messages in the sync window
mRemoteCursorsIterator = new RemoteCursorsIterator(mRemoteSmsSelection,
mRemoteMmsSelection);
}
boolean isSynchronized(final DatabaseWrapper db) {
return isSynchronized(db, mLocalSelection, null, mRemoteSmsSelection,
null, mRemoteMmsSelection, null);
}
void close() {
if (mLocalCursorIterator != null) {
mLocalCursorIterator.close();
}
if (mRemoteCursorsIterator != null) {
mRemoteCursorsIterator.close();
}
}
long scan(final int maxMessagesToScan,
final int maxMessagesToUpdate, final ArrayList<SmsMessage> smsToAdd,
final LongSparseArray<MmsMessage> mmsToAdd,
final ArrayList<LocalDatabaseMessage> messagesToDelete,
final SyncManager.ThreadInfoCache threadInfoCache) {
// Set of local messages matched with the timestamp of a remote message
final Set<DatabaseMessage> matchedLocalMessages = Sets.newHashSet();
// Set of remote messages matched with the timestamp of a local message
final Set<DatabaseMessage> matchedRemoteMessages = Sets.newHashSet();
long lastTimestampMillis = SYNC_STARTING;
// Number of messages scanned local and remote
int localCount = 0;
int remoteCount = 0;
// Seed the initial values of remote and local messages for comparison
DatabaseMessage remoteMessage = mRemoteCursorsIterator.next();
DatabaseMessage localMessage = mLocalCursorIterator.next();
// Iterate through messages on both sides in reverse time order
// Import messages in remote not in local, delete messages in local not in remote
while (localCount + remoteCount < maxMessagesToScan && smsToAdd.size()
+ mmsToAdd.size() + messagesToDelete.size() < maxMessagesToUpdate) {
if (remoteMessage == null && localMessage == null) {
// No more message on both sides - scan complete
lastTimestampMillis = SYNC_COMPLETE;
break;
} else if ((remoteMessage == null && localMessage != null) ||
(localMessage != null && remoteMessage != null &&
localMessage.getTimestampInMillis()
> remoteMessage.getTimestampInMillis())) {
// Found a local message that is not in remote db
// Delete the local message
messagesToDelete.add((LocalDatabaseMessage) localMessage);
lastTimestampMillis = Math.min(lastTimestampMillis,
localMessage.getTimestampInMillis());
// Advance to next local message
localMessage = mLocalCursorIterator.next();
localCount += 1;
} else if ((localMessage == null && remoteMessage != null) ||
(localMessage != null && remoteMessage != null &&
localMessage.getTimestampInMillis()
< remoteMessage.getTimestampInMillis())) {
// Found a remote message that is not in local db
// Add the remote message
saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache);
lastTimestampMillis = Math.min(lastTimestampMillis,
remoteMessage.getTimestampInMillis());
// Advance to next remote message
remoteMessage = mRemoteCursorsIterator.next();
remoteCount += 1;
} else {
// Found remote and local messages at the same timestamp
final long matchedTimestamp = localMessage.getTimestampInMillis();
lastTimestampMillis = Math.min(lastTimestampMillis, matchedTimestamp);
// Get the next local and remote messages
final DatabaseMessage remoteMessagePeek = mRemoteCursorsIterator.next();
final DatabaseMessage localMessagePeek = mLocalCursorIterator.next();
// Check if only one message on each side matches the current timestamp
// by looking at the next messages on both sides. If they are either null
// (meaning no more messages) or having a different timestamp. We want
// to optimize for this since this is the most common case when majority
// of the messages are in sync (so they one-to-one pair up at each timestamp),
// by not allocating the data structures required to compare a set of
// messages from both sides.
if ((remoteMessagePeek == null ||
remoteMessagePeek.getTimestampInMillis() != matchedTimestamp) &&
(localMessagePeek == null ||
localMessagePeek.getTimestampInMillis() != matchedTimestamp)) {
// Optimize the common case where only one message on each side
// that matches the same timestamp
if (!remoteMessage.equals(localMessage)) {
// local != remote
// Delete local message
messagesToDelete.add((LocalDatabaseMessage) localMessage);
// Add remote message
saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache);
}
// Get next local and remote messages
localMessage = localMessagePeek;
remoteMessage = remoteMessagePeek;
localCount += 1;
remoteCount += 1;
} else {
// Rare case in which multiple messages are in the same timestamp
// on either or both sides
// Gather all the matched remote messages
matchedRemoteMessages.clear();
matchedRemoteMessages.add(remoteMessage);
remoteCount += 1;
remoteMessage = remoteMessagePeek;
while (remoteMessage != null &&
remoteMessage.getTimestampInMillis() == matchedTimestamp) {
Assert.isTrue(!matchedRemoteMessages.contains(remoteMessage));
matchedRemoteMessages.add(remoteMessage);
remoteCount += 1;
remoteMessage = mRemoteCursorsIterator.next();
}
// Gather all the matched local messages
matchedLocalMessages.clear();
matchedLocalMessages.add(localMessage);
localCount += 1;
localMessage = localMessagePeek;
while (localMessage != null &&
localMessage.getTimestampInMillis() == matchedTimestamp) {
if (matchedLocalMessages.contains(localMessage)) {
// Duplicate message is local database is deleted
messagesToDelete.add((LocalDatabaseMessage) localMessage);
} else {
matchedLocalMessages.add(localMessage);
}
localCount += 1;
localMessage = mLocalCursorIterator.next();
}
// Delete messages local only
for (final DatabaseMessage msg : Sets.difference(
matchedLocalMessages, matchedRemoteMessages)) {
messagesToDelete.add((LocalDatabaseMessage) msg);
}
// Add messages remote only
for (final DatabaseMessage msg : Sets.difference(
matchedRemoteMessages, matchedLocalMessages)) {
saveMessageToAdd(smsToAdd, mmsToAdd, msg, threadInfoCache);
}
}
}
}
return lastTimestampMillis;
}
DatabaseMessage getLocalMessage() {
return mLocalCursorIterator.next();
}
DatabaseMessage getRemoteMessage() {
return mRemoteCursorsIterator.next();
}
int getLocalPosition() {
return mLocalCursorIterator.getPosition();
}
int getRemotePosition() {
return mRemoteCursorsIterator.getPosition();
}
int getLocalCount() {
return mLocalCursorIterator.getCount();
}
int getRemoteCount() {
return mRemoteCursorsIterator.getCount();
}
/**
* An iterator for a database cursor
*/
interface CursorIterator {
/**
* Move to next element in the cursor
*
* @return The next element (which becomes the current)
*/
public DatabaseMessage next();
/**
* Close the cursor
*/
public void close();
/**
* Get the position
*/
public int getPosition();
/**
* Get the count
*/
public int getCount();
}
private static final String ORDER_BY_DATE_DESC = "date DESC";
// A subquery that selects SMS/MMS messages in Bugle which are also in telephony
private static final String LOCAL_MESSAGES_SELECTION = String.format(
Locale.US,
"(%s NOTNULL)",
MessageColumns.SMS_MESSAGE_URI);
private static final String ORDER_BY_TIMESTAMP_DESC =
MessageColumns.RECEIVED_TIMESTAMP + " DESC";
// TODO : This should move into the provider
private static class LocalMessageQuery {
private static final String[] PROJECTION = new String[] {
MessageColumns._ID,
MessageColumns.RECEIVED_TIMESTAMP,
MessageColumns.SMS_MESSAGE_URI,
MessageColumns.PROTOCOL,
MessageColumns.CONVERSATION_ID,
};
private static final int INDEX_MESSAGE_ID = 0;
private static final int INDEX_MESSAGE_TIMESTAMP = 1;
private static final int INDEX_SMS_MESSAGE_URI = 2;
private static final int INDEX_MESSAGE_SMS_TYPE = 3;
private static final int INDEX_CONVERSATION_ID = 4;
}
/**
* This class provides the same DatabaseMessage interface over a local SMS db message
*/
private static LocalDatabaseMessage getLocalDatabaseMessage(final Cursor cursor) {
if (cursor == null) {
return null;
}
return new LocalDatabaseMessage(
cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_ID),
cursor.getInt(LocalMessageQuery.INDEX_MESSAGE_SMS_TYPE),
cursor.getString(LocalMessageQuery.INDEX_SMS_MESSAGE_URI),
cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_TIMESTAMP),
cursor.getString(LocalMessageQuery.INDEX_CONVERSATION_ID));
}
/**
* The buffered cursor iterator for local SMS
*/
private static class LocalCursorIterator implements CursorIterator {
private Cursor mCursor;
private final DatabaseWrapper mDatabase;
LocalCursorIterator(final DatabaseWrapper database, final String selection)
throws SQLiteException {
mDatabase = database;
try {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SyncCursorPair: Querying for local messages; selection = "
+ selection);
}
mCursor = mDatabase.query(
DatabaseHelper.MESSAGES_TABLE,
LocalMessageQuery.PROJECTION,
selection,
null /*selectionArgs*/,
null/*groupBy*/,
null/*having*/,
ORDER_BY_TIMESTAMP_DESC);
} catch (final SQLiteException e) {
LogUtil.e(TAG, "SyncCursorPair: failed to query local sms/mms", e);
// Can't query local database. So let's throw up the exception and abort sync
// because we may end up import duplicate messages.
throw e;
}
}
@Override
public DatabaseMessage next() {
if (mCursor != null && mCursor.moveToNext()) {
return getLocalDatabaseMessage(mCursor);
}
return null;
}
@Override
public int getCount() {
return (mCursor == null ? 0 : mCursor.getCount());
}
@Override
public int getPosition() {
return (mCursor == null ? 0 : mCursor.getPosition());
}
@Override
public void close() {
if (mCursor != null) {
mCursor.close();
mCursor = null;
}
}
}
/**
* The cursor iterator for remote sms.
* Since SMS and MMS are stored in different tables in telephony provider,
* this class merges the two cursors and provides a unified view of messages
* from both cursors. Note that the order is DESC.
*/
private static class RemoteCursorsIterator implements CursorIterator {
private Cursor mSmsCursor;
private Cursor mMmsCursor;
private DatabaseMessage mNextSms;
private DatabaseMessage mNextMms;
RemoteCursorsIterator(final String smsSelection, final String mmsSelection)
throws SQLiteException {
mSmsCursor = null;
mMmsCursor = null;
try {
final Context context = Factory.get().getApplicationContext();
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SyncCursorPair: Querying for remote SMS; selection = "
+ smsSelection);
}
mSmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Sms.CONTENT_URI,
SmsMessage.getProjection(),
smsSelection,
null /* selectionArgs */,
ORDER_BY_DATE_DESC);
if (mSmsCursor == null) {
LogUtil.w(TAG, "SyncCursorPair: Remote SMS query returned null cursor; "
+ "need to cancel sync");
throw new RuntimeException("Null cursor from remote SMS query");
}
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SyncCursorPair: Querying for remote MMS; selection = "
+ mmsSelection);
}
mMmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Mms.CONTENT_URI,
DatabaseMessages.MmsMessage.getProjection(),
mmsSelection,
null /* selectionArgs */,
ORDER_BY_DATE_DESC);
if (mMmsCursor == null) {
LogUtil.w(TAG, "SyncCursorPair: Remote MMS query returned null cursor; "
+ "need to cancel sync");
throw new RuntimeException("Null cursor from remote MMS query");
}
// Move to the first element in the combined stream from both cursors
mNextSms = getSmsCursorNext();
mNextMms = getMmsCursorNext();
} catch (final SQLiteException e) {
LogUtil.e(TAG, "SyncCursorPair: failed to query remote messages", e);
// If we ignore this, the following code would think there is no remote message
// and will delete all the local sms. We should be cautious here. So instead,
// let's throw the exception to the caller and abort sms sync. We do the same
// thing if either of the remote cursors is null.
throw e;
}
}
@Override
public DatabaseMessage next() {
DatabaseMessage result = null;
if (mNextSms != null && mNextMms != null) {
if (mNextSms.getTimestampInMillis() >= mNextMms.getTimestampInMillis()) {
result = mNextSms;
mNextSms = getSmsCursorNext();
} else {
result = mNextMms;
mNextMms = getMmsCursorNext();
}
} else {
if (mNextSms != null) {
result = mNextSms;
mNextSms = getSmsCursorNext();
} else {
result = mNextMms;
mNextMms = getMmsCursorNext();
}
}
return result;
}
private DatabaseMessage getSmsCursorNext() {
if (mSmsCursor != null && mSmsCursor.moveToNext()) {
return SmsMessage.get(mSmsCursor);
}
return null;
}
private DatabaseMessage getMmsCursorNext() {
if (mMmsCursor != null && mMmsCursor.moveToNext()) {
return MmsMessage.get(mMmsCursor);
}
return null;
}
@Override
// Return approximate cursor position allowing for read ahead on two cursors (hence -1)
public int getPosition() {
return (mSmsCursor == null ? 0 : mSmsCursor.getPosition()) +
(mMmsCursor == null ? 0 : mMmsCursor.getPosition()) - 1;
}
@Override
public int getCount() {
return (mSmsCursor == null ? 0 : mSmsCursor.getCount()) +
(mMmsCursor == null ? 0 : mMmsCursor.getCount());
}
@Override
public void close() {
if (mSmsCursor != null) {
mSmsCursor.close();
mSmsCursor = null;
}
if (mMmsCursor != null) {
mMmsCursor.close();
mMmsCursor = null;
}
}
}
/**
* Type selection for importing sms messages. Only SENT and INBOX messages are imported.
*
* @return The SQL selection for importing sms messages
*/
public static String getSmsTypeSelectionSql() {
return MmsUtils.getSmsTypeSelectionSql();
}
/**
* Type selection for importing mms messages.
*
* Criteria:
* MESSAGE_BOX is INBOX, SENT or OUTBOX
* MESSAGE_TYPE is SEND_REQ (sent), RETRIEVE_CONF (received) or NOTIFICATION_IND (download)
*
* @return The SQL selection for importing mms messages. This selects the message type,
* not including the selection on timestamp.
*/
public static String getMmsTypeSelectionSql() {
return MmsUtils.getMmsTypeSelectionSql();
}
/**
* Get a SQL selection string using an existing selection and time window limits
* The limits are not applied if the value is < 0
*
* @param typeSelection The existing selection
* @param from The inclusive lower bound
* @param to The exclusive upper bound
* @return The created SQL selection
*/
private static String getTimeConstrainedQuery(final String typeSelection,
final String timeColumn, final long from, final long to,
final String threadColumn, final String threadId) {
final StringBuilder queryBuilder = new StringBuilder();
queryBuilder.append(typeSelection);
if (from > 0) {
queryBuilder.append(" AND ").append(timeColumn).append(">=").append(from);
}
if (to > 0) {
queryBuilder.append(" AND ").append(timeColumn).append("<").append(to);
}
if (!TextUtils.isEmpty(threadColumn) && !TextUtils.isEmpty(threadId)) {
queryBuilder.append(" AND ").append(threadColumn).append("=").append(threadId);
}
return queryBuilder.toString();
}
private static final String[] COUNT_PROJECTION = new String[] { "count()" };
private static int getCountFromCursor(final Cursor cursor) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
// We should only return a number if we were able to read it from the cursor.
// Otherwise, we throw an exception to cancel the sync.
String cursorDesc = "";
if (cursor == null) {
cursorDesc = "null";
} else if (cursor.getCount() == 0) {
cursorDesc = "empty";
}
throw new IllegalArgumentException("Cannot get count from " + cursorDesc + " cursor");
}
private void saveMessageToAdd(final List<SmsMessage> smsToAdd,
final LongSparseArray<MmsMessage> mmsToAdd, final DatabaseMessage message,
final ThreadInfoCache threadInfoCache) {
long threadId;
if (message.getProtocol() == MessageData.PROTOCOL_MMS) {
final MmsMessage mms = (MmsMessage) message;
mmsToAdd.append(mms.getId(), mms);
threadId = mms.mThreadId;
} else {
final SmsMessage sms = (SmsMessage) message;
smsToAdd.add(sms);
threadId = sms.mThreadId;
}
// Cache the lookup and canonicalization of the phone number outside of the transaction...
threadInfoCache.getThreadRecipients(threadId);
}
/**
* Check if SMS has been synchronized. We compare the counts of messages on both
* sides and return true if they are equal.
*
* Note that this may not be the most reliable way to tell if messages are in sync.
* For example, the local misses one message and has one obsolete message.
* However, we have background sms sync once a while, also some other events might
* trigger a full sync. So we will eventually catch up. And this should be rare to
* happen.
*
* @return If sms is in sync with telephony sms/mms providers
*/
private static boolean isSynchronized(final DatabaseWrapper db, final String localSelection,
final String[] localSelectionArgs, final String smsSelection,
final String[] smsSelectionArgs, final String mmsSelection,
final String[] mmsSelectionArgs) {
final Context context = Factory.get().getApplicationContext();
Cursor localCursor = null;
Cursor remoteSmsCursor = null;
Cursor remoteMmsCursor = null;
try {
localCursor = db.query(
DatabaseHelper.MESSAGES_TABLE,
COUNT_PROJECTION,
localSelection,
localSelectionArgs,
null/*groupBy*/,
null/*having*/,
null/*orderBy*/);
final int localCount = getCountFromCursor(localCursor);
remoteSmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Sms.CONTENT_URI,
COUNT_PROJECTION,
smsSelection,
smsSelectionArgs,
null/*orderBy*/);
final int smsCount = getCountFromCursor(remoteSmsCursor);
remoteMmsCursor = SqliteWrapper.query(
context,
context.getContentResolver(),
Mms.CONTENT_URI,
COUNT_PROJECTION,
mmsSelection,
mmsSelectionArgs,
null/*orderBy*/);
final int mmsCount = getCountFromCursor(remoteMmsCursor);
final int remoteCount = smsCount + mmsCount;
final boolean isInSync = (localCount == remoteCount);
if (isInSync) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncCursorPair: Same # of local and remote messages = "
+ localCount);
}
} else {
LogUtil.i(TAG, "SyncCursorPair: Not in sync; # local messages = " + localCount
+ ", # remote message = " + remoteCount);
}
return isInSync;
} catch (final Exception e) {
LogUtil.e(TAG, "SyncCursorPair: failed to query local or remote message counts", e);
// If something is wrong in querying database, assume we are synced so
// we don't retry indefinitely
} finally {
if (localCursor != null) {
localCursor.close();
}
if (remoteSmsCursor != null) {
remoteSmsCursor.close();
}
if (remoteMmsCursor != null) {
remoteMmsCursor.close();
}
}
return true;
}
}
@@ -0,0 +1,383 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.database.Cursor;
import android.database.sqlite.SQLiteConstraintException;
import android.provider.Telephony;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Sms;
import android.text.TextUtils;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.pdu.PduHeaders;
import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.MmsMessage;
import com.android.messaging.sms.DatabaseMessages.SmsMessage;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.LogUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
/**
* Update local database with a batch of messages to add/delete in one transaction
*/
class SyncMessageBatch {
private static final String TAG = LogUtil.BUGLE_TAG;
// Variables used during executeAction
private final HashSet<String> mConversationsToUpdate;
// Cache of thread->conversationId map
private final ThreadInfoCache mCache;
// Set of SMS messages to add
private final ArrayList<SmsMessage> mSmsToAdd;
// Set of MMS messages to add
private final ArrayList<MmsMessage> mMmsToAdd;
// Set of local messages to delete
private final ArrayList<LocalDatabaseMessage> mMessagesToDelete;
SyncMessageBatch(final ArrayList<SmsMessage> smsToAdd,
final ArrayList<MmsMessage> mmsToAdd,
final ArrayList<LocalDatabaseMessage> messagesToDelete,
final ThreadInfoCache cache) {
mSmsToAdd = smsToAdd;
mMmsToAdd = mmsToAdd;
mMessagesToDelete = messagesToDelete;
mCache = cache;
mConversationsToUpdate = new HashSet<String>();
}
void updateLocalDatabase() {
// Perform local database changes in one transaction
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
// Store all the SMS messages
for (final SmsMessage sms : mSmsToAdd) {
storeSms(db, sms);
}
// Store all the MMS messages
for (final MmsMessage mms : mMmsToAdd) {
storeMms(db, mms);
}
// Keep track of conversations with messages deleted
for (final LocalDatabaseMessage message : mMessagesToDelete) {
mConversationsToUpdate.add(message.getConversationId());
}
// Batch delete local messages
batchDelete(db, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID,
messageListToIds(mMessagesToDelete));
for (final LocalDatabaseMessage message : mMessagesToDelete) {
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SyncMessageBatch: Deleted message " + message.getLocalId()
+ " for SMS/MMS " + message.getUri() + " with timestamp "
+ message.getTimestampInMillis());
}
}
// Update conversation state for imported messages, like snippet,
updateConversations(db);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
private static String[] messageListToIds(final List<LocalDatabaseMessage> messagesToDelete) {
final String[] ids = new String[messagesToDelete.size()];
for (int i = 0; i < ids.length; i++) {
ids[i] = Long.toString(messagesToDelete.get(i).getLocalId());
}
return ids;
}
/**
* Store the SMS message into local database.
*
* @param sms
*/
private void storeSms(final DatabaseWrapper db, final SmsMessage sms) {
if (sms.mBody == null) {
LogUtil.w(TAG, "SyncMessageBatch: SMS " + sms.mUri + " has no body; adding empty one");
// try to fix it
sms.mBody = "";
}
if (TextUtils.isEmpty(sms.mAddress)) {
LogUtil.e(TAG, "SyncMessageBatch: SMS has no address; using unknown sender");
// try to fix it
sms.mAddress = ParticipantData.getUnknownSenderDestination();
}
// TODO : We need to also deal with messages in a failed/retry state
final boolean isOutgoing = sms.mType != Sms.MESSAGE_TYPE_INBOX;
final String otherPhoneNumber = sms.mAddress;
// A forced resync of all messages should still keep the archived states.
// The database upgrade code notifies sync manager of this. We need to
// honor the original customization to this conversation if created.
final String conversationId = mCache.getOrCreateConversation(db, sms.mThreadId, sms.mSubId,
DataModel.get().getSyncManager().getCustomizationForThread(sms.mThreadId));
if (conversationId == null) {
// Cannot create conversation for this message? This should not happen.
LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for SMS thread "
+ sms.mThreadId);
return;
}
final ParticipantData self = ParticipantData.getSelfParticipant(sms.getSubId());
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
final ParticipantData sender = isOutgoing ?
self :
ParticipantData.getFromRawPhoneBySimLocale(otherPhoneNumber, sms.getSubId());
final String participantId = (isOutgoing ? selfId :
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender));
final int bugleStatus = bugleStatusForSms(isOutgoing, sms.mType, sms.mStatus);
final MessageData message = MessageData.createSmsMessage(
sms.mUri,
participantId,
selfId,
conversationId,
bugleStatus,
sms.mSeen,
sms.mRead,
sms.mTimestampSentInMillis,
sms.mTimestampInMillis,
sms.mBody);
// Inserting sms content into messages table
try {
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
} catch (SQLiteConstraintException e) {
rethrowSQLiteConstraintExceptionWithDetails(e, db, sms.mUri, sms.mThreadId,
conversationId, selfId, participantId);
}
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId()
+ " for SMS " + message.getSmsMessageUri() + " received at "
+ message.getReceivedTimeStamp());
}
// Keep track of updated conversation for later updating the conversation snippet, etc.
mConversationsToUpdate.add(conversationId);
}
public static int bugleStatusForSms(final boolean isOutgoing, final int type,
final int status) {
int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN;
// For a message we sync either
if (isOutgoing) {
// Outgoing message not yet been sent
if (type == Telephony.Sms.MESSAGE_TYPE_FAILED ||
type == Telephony.Sms.MESSAGE_TYPE_OUTBOX ||
type == Telephony.Sms.MESSAGE_TYPE_QUEUED ||
(type == Telephony.Sms.MESSAGE_TYPE_SENT &&
status == Telephony.Sms.STATUS_FAILED)) {
// Not sent counts as failed and available for manual resend
bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED;
} else if (status == Sms.STATUS_COMPLETE) {
bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED;
} else {
// Otherwise outgoing message is complete
bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE;
}
} else {
// All incoming SMS messages are complete
bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE;
}
return bugleStatus;
}
/**
* Store the MMS message into local database
*
* @param mms
*/
private void storeMms(final DatabaseWrapper db, final MmsMessage mms) {
if (mms.mParts.size() < 1) {
LogUtil.w(TAG, "SyncMessageBatch: MMS " + mms.mUri + " has no parts");
}
// TODO : We need to also deal with messages in a failed/retry state
final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX;
final boolean isNotification = (mms.mMmsMessageType ==
PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND);
final String senderId = mms.mSender;
// A forced resync of all messages should still keep the archived states.
// The database upgrade code notifies sync manager of this. We need to
// honor the original customization to this conversation if created.
final String conversationId = mCache.getOrCreateConversation(db, mms.mThreadId, mms.mSubId,
DataModel.get().getSyncManager().getCustomizationForThread(mms.mThreadId));
if (conversationId == null) {
LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for MMS thread "
+ mms.mThreadId);
return;
}
final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId());
final String selfId =
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self);
final ParticipantData sender = isOutgoing ?
self : ParticipantData.getFromRawPhoneBySimLocale(senderId, mms.getSubId());
final String participantId = (isOutgoing ? selfId :
BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender));
final int bugleStatus = MmsUtils.bugleStatusForMms(isOutgoing, isNotification, mms.mType);
// Import message and all of the parts.
// TODO : For now we are importing these in the order we found them in the MMS
// database. Ideally we would load and parse the SMIL which describes how the parts relate
// to one another.
// TODO: Need to set correct status on message
final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId,
selfId, bugleStatus);
// Inserting mms content into messages table
try {
BugleDatabaseOperations.insertNewMessageInTransaction(db, message);
} catch (SQLiteConstraintException e) {
rethrowSQLiteConstraintExceptionWithDetails(e, db, mms.mUri, mms.mThreadId,
conversationId, selfId, participantId);
}
if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId()
+ " for MMS " + message.getSmsMessageUri() + " received at "
+ message.getReceivedTimeStamp());
}
// Keep track of updated conversation for later updating the conversation snippet, etc.
mConversationsToUpdate.add(conversationId);
}
// TODO: Remove this after we no longer see this crash (b/18375758)
private static void rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e,
DatabaseWrapper db, String messageUri, long threadId, String conversationId,
String selfId, String senderId) {
// Add some extra debug information to the exception for tracking down b/18375758.
// The default detail message for SQLiteConstraintException tells us that a foreign
// key constraint failed, but not which one! Messages have foreign keys to 3 tables:
// conversations, participants (self), participants (sender). We'll query each one
// to determine which one(s) violated the constraint, and then throw a new exception
// with those details.
String foundConversationId = null;
Cursor cursor = null;
try {
// Look for an existing conversation in the db with the conversation id
cursor = db.rawQuery("SELECT " + ConversationColumns._ID
+ " FROM " + DatabaseHelper.CONVERSATIONS_TABLE
+ " WHERE " + ConversationColumns._ID + "=" + conversationId,
null);
if (cursor != null && cursor.moveToFirst()) {
Assert.isTrue(cursor.getCount() == 1);
foundConversationId = cursor.getString(0);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
ParticipantData foundSelfParticipant =
BugleDatabaseOperations.getExistingParticipant(db, selfId);
ParticipantData foundSenderParticipant =
BugleDatabaseOperations.getExistingParticipant(db, senderId);
String errorMsg = "SQLiteConstraintException while inserting message for " + messageUri
+ "; conversation id from getOrCreateConversation = " + conversationId
+ " (lookup thread = " + threadId + "), found conversation id = "
+ foundConversationId + ", found self participant = "
+ LogUtil.sanitizePII(foundSelfParticipant.getNormalizedDestination())
+ " (lookup id = " + selfId + "), found sender participant = "
+ LogUtil.sanitizePII(foundSenderParticipant.getNormalizedDestination())
+ " (lookup id = " + senderId + ")";
throw new RuntimeException(errorMsg, e);
}
/**
* Use the tracked latest message info to update conversations, including
* latest chat message and sort timestamp.
*/
private void updateConversations(final DatabaseWrapper db) {
for (final String conversationId : mConversationsToUpdate) {
if (BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(db,
conversationId)) {
continue;
}
final boolean archived = mCache.isArchived(conversationId);
// Always attempt to auto-switch conversation self id for sync/import case.
BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(db,
conversationId, true /*shouldAutoSwitchSelfId*/, archived /*keepArchived*/);
}
}
/**
* Batch delete database rows by matching a column with a list of values, usually some
* kind of IDs.
*
* @param table
* @param column
* @param ids
* @return Total number of deleted messages
*/
private static int batchDelete(final DatabaseWrapper db, final String table,
final String column, final String[] ids) {
int totalDeleted = 0;
final int totalIds = ids.length;
for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) {
final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding
final int count = end - start;
final String batchSelection = String.format(
Locale.US,
"%s IN %s",
column,
MmsUtils.getSqlInOperand(count));
final String[] batchSelectionArgs = Arrays.copyOfRange(ids, start, end);
final int deleted = db.delete(
table,
batchSelection,
batchSelectionArgs);
totalDeleted += deleted;
}
return totalDeleted;
}
}
@@ -0,0 +1,637 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.SystemClock;
import android.provider.Telephony.Mms;
import android.support.v4.util.LongSparseArray;
import com.android.messaging.Factory;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.SyncManager;
import com.android.messaging.datamodel.SyncManager.ThreadInfoCache;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.mmslib.SqliteWrapper;
import com.android.messaging.sms.DatabaseMessages;
import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage;
import com.android.messaging.sms.DatabaseMessages.MmsMessage;
import com.android.messaging.sms.DatabaseMessages.SmsMessage;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservices;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.ContentType;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Action used to sync messages from smsmms db to local database
*/
public class SyncMessagesAction extends Action implements Parcelable {
static final long SYNC_FAILED = Long.MIN_VALUE;
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
private static final String KEY_START_TIMESTAMP = "start_timestamp";
private static final String KEY_MAX_UPDATE = "max_update";
private static final String KEY_LOWER_BOUND = "lower_bound";
private static final String KEY_UPPER_BOUND = "upper_bound";
private static final String BUNDLE_KEY_LAST_TIMESTAMP = "last_timestamp";
private static final String BUNDLE_KEY_SMS_MESSAGES = "sms_to_add";
private static final String BUNDLE_KEY_MMS_MESSAGES = "mms_to_add";
private static final String BUNDLE_KEY_MESSAGES_TO_DELETE = "messages_to_delete";
/**
* Start a full sync (backed off a few seconds to avoid pulling sending/receiving messages).
*/
public static void fullSync() {
final BugleGservices bugleGservices = BugleGservices.get();
final long smsSyncBackoffTimeMillis = bugleGservices.getLong(
BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS,
BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
final long now = System.currentTimeMillis();
// TODO: Could base this off most recent message in db but now should be okay...
final long startTimestamp = now - smsSyncBackoffTimeMillis;
final SyncMessagesAction action = new SyncMessagesAction(-1L, startTimestamp,
0, startTimestamp);
action.start();
}
/**
* Start an incremental sync to pull messages since last sync (backed off a few seconds)..
*/
public static void sync() {
final BugleGservices bugleGservices = BugleGservices.get();
final long smsSyncBackoffTimeMillis = bugleGservices.getLong(
BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS,
BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
final long now = System.currentTimeMillis();
// TODO: Could base this off most recent message in db but now should be okay...
final long startTimestamp = now - smsSyncBackoffTimeMillis;
sync(startTimestamp);
}
/**
* Start an incremental sync when the application starts up (no back off as not yet
* sending/receiving).
*/
public static void immediateSync() {
final long now = System.currentTimeMillis();
// TODO: Could base this off most recent message in db but now should be okay...
final long startTimestamp = now;
sync(startTimestamp);
}
private static void sync(final long startTimestamp) {
if (!OsUtil.hasSmsPermission()) {
// Sync requires READ_SMS permission
return;
}
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
// Lower bound is end of previous sync
final long syncLowerBoundTimeMillis = prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
final SyncMessagesAction action = new SyncMessagesAction(syncLowerBoundTimeMillis,
startTimestamp, 0, startTimestamp);
action.start();
}
private SyncMessagesAction(final long lowerBound, final long upperBound,
final int maxMessagesToUpdate, final long startTimestamp) {
actionParameters.putLong(KEY_LOWER_BOUND, lowerBound);
actionParameters.putLong(KEY_UPPER_BOUND, upperBound);
actionParameters.putInt(KEY_MAX_UPDATE, maxMessagesToUpdate);
actionParameters.putLong(KEY_START_TIMESTAMP, startTimestamp);
}
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Request to sync messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis + " (start timestamp = "
+ startTimestamp + ", message update limit = " + initialMaxMessagesToUpdate
+ ")");
}
final SyncManager syncManager = DataModel.get().getSyncManager();
if (lowerBoundTimeMillis >= 0) {
// Cursors
final SyncCursorPair cursors = new SyncCursorPair(-1L, lowerBoundTimeMillis);
final boolean inSync = cursors.isSynchronized(db);
if (!inSync) {
if (syncManager.delayUntilFullSync(startTimestamp) == 0) {
lowerBoundTimeMillis = -1;
actionParameters.putLong(KEY_LOWER_BOUND, lowerBoundTimeMillis);
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Messages before "
+ lowerBoundTimeMillis + " not in sync; promoting to full sync");
}
} else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Messages before "
+ lowerBoundTimeMillis + " not in sync; will do incremental sync");
}
} else {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Messages before " + lowerBoundTimeMillis
+ " are in sync");
}
}
}
// Check if sync allowed (can be too soon after last or one is already running)
if (syncManager.shouldSync(lowerBoundTimeMillis < 0, startTimestamp)) {
syncManager.startSyncBatch(upperBoundTimeMillis);
requestBackgroundWork();
}
return null;
}
@Override
protected Bundle doBackgroundWork() {
final BugleGservices bugleGservices = BugleGservices.get();
final DatabaseWrapper db = DataModel.get().getDatabase();
final int maxMessagesToScan = bugleGservices.getInt(
BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN,
BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT);
final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
final int smsSyncSubsequentBatchSizeMin = bugleGservices.getInt(
BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN,
BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN_DEFAULT);
final int smsSyncSubsequentBatchSizeMax = bugleGservices.getInt(
BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX,
BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX_DEFAULT);
// Cap sync size to GServices limits
final int maxMessagesToUpdate = Math.max(smsSyncSubsequentBatchSizeMin,
Math.min(initialMaxMessagesToUpdate, smsSyncSubsequentBatchSizeMax));
final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
LogUtil.i(TAG, "SyncMessagesAction: Starting batch for messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis
+ " (message update limit = " + maxMessagesToUpdate + ", message scan limit = "
+ maxMessagesToScan + ")");
// Clear last change time so that we can work out if this batch is dirty when it completes
final SyncManager syncManager = DataModel.get().getSyncManager();
// Clear the singleton cache that maps threads to recipients and to conversations.
final SyncManager.ThreadInfoCache cache = syncManager.getThreadInfoCache();
cache.clear();
// Sms messages to store
final ArrayList<SmsMessage> smsToAdd = new ArrayList<SmsMessage>();
// Mms messages to store
final LongSparseArray<MmsMessage> mmsToAdd = new LongSparseArray<MmsMessage>();
// List of local SMS/MMS to remove
final ArrayList<LocalDatabaseMessage> messagesToDelete =
new ArrayList<LocalDatabaseMessage>();
long lastTimestampMillis = SYNC_FAILED;
if (syncManager.isSyncing(upperBoundTimeMillis)) {
// Cursors
final SyncCursorPair cursors = new SyncCursorPair(lowerBoundTimeMillis,
upperBoundTimeMillis);
// Actually compare the messages using cursor pair
lastTimestampMillis = syncCursorPair(db, cursors, smsToAdd, mmsToAdd,
messagesToDelete, maxMessagesToScan, maxMessagesToUpdate, cache);
}
final Bundle response = new Bundle();
// If comparison succeeds bundle up the changes for processing in ActionService
if (lastTimestampMillis > SYNC_FAILED) {
final ArrayList<MmsMessage> mmsToAddList = new ArrayList<MmsMessage>();
for (int i = 0; i < mmsToAdd.size(); i++) {
final MmsMessage mms = mmsToAdd.valueAt(i);
mmsToAddList.add(mms);
}
response.putParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES, smsToAdd);
response.putParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES, mmsToAddList);
response.putParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE, messagesToDelete);
}
response.putLong(BUNDLE_KEY_LAST_TIMESTAMP, lastTimestampMillis);
return response;
}
/**
* Compare messages based on timestamp and uri
* @param db local database wrapper
* @param cursors cursor pair holding references to local and remote messages
* @param smsToAdd newly found sms messages to add
* @param mmsToAdd newly found mms messages to add
* @param messagesToDelete messages not found needing deletion
* @param maxMessagesToScan max messages to scan for changes
* @param maxMessagesToUpdate max messages to return for updates
* @param cache cache for conversation id / thread id / recipient set mapping
* @return timestamp of the oldest message seen during the sync scan
*/
private long syncCursorPair(final DatabaseWrapper db, final SyncCursorPair cursors,
final ArrayList<SmsMessage> smsToAdd, final LongSparseArray<MmsMessage> mmsToAdd,
final ArrayList<LocalDatabaseMessage> messagesToDelete, final int maxMessagesToScan,
final int maxMessagesToUpdate, final ThreadInfoCache cache) {
long lastTimestampMillis;
final long startTimeMillis = SystemClock.elapsedRealtime();
// Number of messages scanned local and remote
int localPos = 0;
int remotePos = 0;
int localTotal = 0;
int remoteTotal = 0;
// Scan through the messages on both sides and prepare messages for local message table
// changes (including adding and deleting)
try {
cursors.query(db);
localTotal = cursors.getLocalCount();
remoteTotal = cursors.getRemoteCount();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Scanning cursors (local count = " + localTotal
+ ", remote count = " + remoteTotal + ", message update limit = "
+ maxMessagesToUpdate + ", message scan limit = " + maxMessagesToScan
+ ")");
}
lastTimestampMillis = cursors.scan(maxMessagesToScan, maxMessagesToUpdate,
smsToAdd, mmsToAdd, messagesToDelete, cache);
localPos = cursors.getLocalPosition();
remotePos = cursors.getRemotePosition();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Scanned cursors (local position = " + localPos
+ " of " + localTotal + ", remote position = " + remotePos + " of "
+ remoteTotal + ")");
}
// Batch loading the parts of the MMS messages in this batch
loadMmsParts(mmsToAdd);
// Lookup senders for incoming mms messages
setMmsSenders(mmsToAdd, cache);
} catch (final SQLiteException e) {
LogUtil.e(TAG, "SyncMessagesAction: Database exception", e);
// Let's abort
lastTimestampMillis = SYNC_FAILED;
} catch (final Exception e) {
// We want to catch anything unexpected since this is running in a separate thread
// and any unexpected exception will just fail this thread silently.
// Let's crash for dogfooders!
LogUtil.wtf(TAG, "SyncMessagesAction: unexpected failure in scan", e);
lastTimestampMillis = SYNC_FAILED;
} finally {
if (cursors != null) {
cursors.close();
}
}
final long endTimeMillis = SystemClock.elapsedRealtime();
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: Scan complete (took "
+ (endTimeMillis - startTimeMillis) + " ms). " + smsToAdd.size()
+ " remote SMS to add, " + mmsToAdd.size() + " MMS to add, "
+ messagesToDelete.size() + " local messages to delete. "
+ "Oldest timestamp seen = " + lastTimestampMillis);
}
return lastTimestampMillis;
}
/**
* Perform local database updates and schedule follow on sync actions
*/
@Override
protected Object processBackgroundResponse(final Bundle response) {
final long lastTimestampMillis = response.getLong(BUNDLE_KEY_LAST_TIMESTAMP);
final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND);
final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND);
final int maxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE);
final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP);
// Check with the sync manager if any conflicting updates have been made to databases
final SyncManager syncManager = DataModel.get().getSyncManager();
final boolean orphan = !syncManager.isSyncing(upperBoundTimeMillis);
// lastTimestampMillis used to indicate failure
if (orphan) {
// This batch does not match current in progress timestamp.
LogUtil.w(TAG, "SyncMessagesAction: Ignoring orphan sync batch for messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis);
} else {
final boolean dirty = syncManager.isBatchDirty(lastTimestampMillis);
if (lastTimestampMillis == SYNC_FAILED) {
LogUtil.e(TAG, "SyncMessagesAction: Sync failed - terminating");
// Failed - update last sync times to throttle our failure rate
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
// Save sync completion time so next sync will start from here
prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp);
// Remember last full sync so that don't start background full sync right away
prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp);
syncManager.complete();
} else if (dirty) {
LogUtil.w(TAG, "SyncMessagesAction: Redoing dirty sync batch of messages from "
+ lowerBoundTimeMillis + " to " + upperBoundTimeMillis);
// Redo this batch
final SyncMessagesAction nextBatch =
new SyncMessagesAction(lowerBoundTimeMillis, upperBoundTimeMillis,
maxMessagesToUpdate, startTimestamp);
syncManager.startSyncBatch(upperBoundTimeMillis);
requestBackgroundWork(nextBatch);
} else {
// Succeeded
final ArrayList<SmsMessage> smsToAdd =
response.getParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES);
final ArrayList<MmsMessage> mmsToAdd =
response.getParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES);
final ArrayList<LocalDatabaseMessage> messagesToDelete =
response.getParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE);
final int messagesUpdated = smsToAdd.size() + mmsToAdd.size()
+ messagesToDelete.size();
// Perform local database changes in one transaction
long txnTimeMillis = 0;
if (messagesUpdated > 0) {
final long startTimeMillis = SystemClock.elapsedRealtime();
final SyncMessageBatch batch = new SyncMessageBatch(smsToAdd, mmsToAdd,
messagesToDelete, syncManager.getThreadInfoCache());
batch.updateLocalDatabase();
final long endTimeMillis = SystemClock.elapsedRealtime();
txnTimeMillis = endTimeMillis - startTimeMillis;
LogUtil.i(TAG, "SyncMessagesAction: Updated local database "
+ "(took " + txnTimeMillis + " ms). Added "
+ smsToAdd.size() + " SMS, added " + mmsToAdd.size() + " MMS, deleted "
+ messagesToDelete.size() + " messages.");
// TODO: Investigate whether we can make this more fine-grained.
MessagingContentProvider.notifyEverythingChanged();
} else {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: No local database updates to make");
}
if (!syncManager.getHasFirstSyncCompleted()) {
// If we have never completed a sync before (fresh install) and there are
// no messages, still inform the UI of a change so it can update syncing
// messages shown to the user
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyPartsChanged();
}
}
// Determine if there are more messages that need to be scanned
if (lastTimestampMillis >= 0 && lastTimestampMillis >= lowerBoundTimeMillis) {
if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
LogUtil.d(TAG, "SyncMessagesAction: More messages to sync; scheduling next "
+ "sync batch now.");
}
// Include final millisecond of last sync in next sync
final long newUpperBoundTimeMillis = lastTimestampMillis + 1;
final int newMaxMessagesToUpdate = nextBatchSize(messagesUpdated,
txnTimeMillis);
final SyncMessagesAction nextBatch =
new SyncMessagesAction(lowerBoundTimeMillis, newUpperBoundTimeMillis,
newMaxMessagesToUpdate, startTimestamp);
// Proceed with next batch
syncManager.startSyncBatch(newUpperBoundTimeMillis);
requestBackgroundWork(nextBatch);
} else {
final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
// Save sync completion time so next sync will start from here
prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp);
if (lowerBoundTimeMillis < 0) {
// Remember last full sync so that don't start another full sync right away
prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp);
}
final long now = System.currentTimeMillis();
// After any sync check if new messages have arrived
final SyncCursorPair recents = new SyncCursorPair(startTimestamp, now);
final SyncCursorPair olders = new SyncCursorPair(-1L, startTimestamp);
final DatabaseWrapper db = DataModel.get().getDatabase();
if (!recents.isSynchronized(db)) {
LogUtil.i(TAG, "SyncMessagesAction: Changed messages after sync; "
+ "scheduling an incremental sync now.");
// Just add a new batch for recent messages
final SyncMessagesAction nextBatch =
new SyncMessagesAction(startTimestamp, now, 0, startTimestamp);
syncManager.startSyncBatch(now);
requestBackgroundWork(nextBatch);
// After partial sync verify sync state
} else if (lowerBoundTimeMillis >= 0 && !olders.isSynchronized(db)) {
// Add a batch going back to start of time
LogUtil.w(TAG, "SyncMessagesAction: Changed messages before sync batch; "
+ "scheduling a full sync now.");
final SyncMessagesAction nextBatch =
new SyncMessagesAction(-1L, startTimestamp, 0, startTimestamp);
syncManager.startSyncBatch(startTimestamp);
requestBackgroundWork(nextBatch);
} else {
LogUtil.i(TAG, "SyncMessagesAction: All messages now in sync");
// All done, in sync
syncManager.complete();
}
}
// Either sync should be complete or we should have a follow up request
Assert.isTrue(hasBackgroundActions() || !syncManager.isSyncing());
}
}
return null;
}
/**
* Decide the next batch size based on the stats we collected with past batch
* @param messagesUpdated number of messages updated in this batch
* @param txnTimeMillis time the transaction took in ms
* @return Target number of messages to sync for next batch
*/
private static int nextBatchSize(final int messagesUpdated, final long txnTimeMillis) {
final BugleGservices bugleGservices = BugleGservices.get();
final long smsSyncSubsequentBatchTimeLimitMillis = bugleGservices.getLong(
BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS,
BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT);
if (txnTimeMillis <= 0) {
return 0;
}
// Number of messages we can sync within the batch time limit using
// the average sync time calculated based on the stats we collected
// in previous batch
return (int) ((double) (messagesUpdated) / (double) txnTimeMillis
* smsSyncSubsequentBatchTimeLimitMillis);
}
/**
* Batch loading MMS parts for the messages in current batch
*/
private void loadMmsParts(final LongSparseArray<MmsMessage> mmses) {
final Context context = Factory.get().getApplicationContext();
final int totalIds = mmses.size();
for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) {
final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding
final int count = end - start;
final String batchSelection = String.format(
Locale.US,
"%s != '%s' AND %s IN %s",
Mms.Part.CONTENT_TYPE,
ContentType.APP_SMIL,
Mms.Part.MSG_ID,
MmsUtils.getSqlInOperand(count));
final String[] batchSelectionArgs = new String[count];
for (int i = 0; i < count; i++) {
batchSelectionArgs[i] = Long.toString(mmses.valueAt(start + i).getId());
}
final Cursor cursor = SqliteWrapper.query(
context,
context.getContentResolver(),
MmsUtils.MMS_PART_CONTENT_URI,
DatabaseMessages.MmsPart.PROJECTION,
batchSelection,
batchSelectionArgs,
null/*sortOrder*/);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
// Delay loading the media content for parsing for efficiency
// TODO: load the media and fill in the dimensions when
// we actually display it
final DatabaseMessages.MmsPart part =
DatabaseMessages.MmsPart.get(cursor, false/*loadMedia*/);
final DatabaseMessages.MmsMessage mms = mmses.get(part.mMessageId);
if (mms != null) {
mms.addPart(part);
}
}
} finally {
cursor.close();
}
}
}
}
/**
* Batch loading MMS sender for the messages in current batch
*/
private void setMmsSenders(final LongSparseArray<MmsMessage> mmses,
final ThreadInfoCache cache) {
// Store all the MMS messages
for (int i = 0; i < mmses.size(); i++) {
final MmsMessage mms = mmses.valueAt(i);
final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX;
String senderId = null;
if (!isOutgoing) {
// We only need to find out sender phone number for received message
senderId = getMmsSender(mms, cache);
if (senderId == null) {
LogUtil.w(TAG, "SyncMessagesAction: Could not find sender of incoming MMS "
+ "message " + mms.getUri() + "; using 'unknown sender' instead");
senderId = ParticipantData.getUnknownSenderDestination();
}
}
mms.setSender(senderId);
}
}
/**
* Find out the sender of an MMS message
*/
private String getMmsSender(final MmsMessage mms, final ThreadInfoCache cache) {
final List<String> recipients = cache.getThreadRecipients(mms.mThreadId);
Assert.notNull(recipients);
Assert.isTrue(recipients.size() > 0);
if (recipients.size() == 1
&& recipients.get(0).equals(ParticipantData.getUnknownSenderDestination())) {
LogUtil.w(TAG, "SyncMessagesAction: MMS message " + mms.mUri + " has unknown sender "
+ "(thread id = " + mms.mThreadId + ")");
}
return MmsUtils.getMmsSender(recipients, mms.mUri);
}
private SyncMessagesAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<SyncMessagesAction> CREATOR
= new Parcelable.Creator<SyncMessagesAction>() {
@Override
public SyncMessagesAction createFromParcel(final Parcel in) {
return new SyncMessagesAction(in);
}
@Override
public SyncMessagesAction[] newArray(final int size) {
return new SyncMessagesAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.util.Assert;
public class UpdateConversationArchiveStatusAction extends Action {
public static void archiveConversation(final String conversationId) {
final UpdateConversationArchiveStatusAction action =
new UpdateConversationArchiveStatusAction(conversationId, true /* isArchive */);
action.start();
}
public static void unarchiveConversation(final String conversationId) {
final UpdateConversationArchiveStatusAction action =
new UpdateConversationArchiveStatusAction(conversationId, false /* isArchive */);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_IS_ARCHIVE = "is_archive";
protected UpdateConversationArchiveStatusAction(
final String conversationId, final boolean isArchive) {
Assert.isTrue(!TextUtils.isEmpty(conversationId));
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putBoolean(KEY_IS_ARCHIVE, isArchive);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final boolean isArchived = actionParameters.getBoolean(KEY_IS_ARCHIVE);
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
BugleDatabaseOperations.updateConversationArchiveStatusInTransaction(
db, conversationId, isArchived);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
return null;
}
protected UpdateConversationArchiveStatusAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateConversationArchiveStatusAction> CREATOR
= new Parcelable.Creator<UpdateConversationArchiveStatusAction>() {
@Override
public UpdateConversationArchiveStatusAction createFromParcel(final Parcel in) {
return new UpdateConversationArchiveStatusAction(in);
}
@Override
public UpdateConversationArchiveStatusAction[] newArray(final int size) {
return new UpdateConversationArchiveStatusAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,156 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.util.Assert;
/**
* Action used to update conversation options such as notification settings.
*/
public class UpdateConversationOptionsAction extends Action
implements Parcelable {
/**
* Enable/disable conversation notifications.
*/
public static void enableConversationNotifications(final String conversationId,
final boolean enableNotification) {
Assert.notNull(conversationId);
final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction(
conversationId, enableNotification, null, null);
action.start();
}
/**
* Sets conversation notification sound.
*/
public static void setConversationNotificationSound(final String conversationId,
final String ringtoneUri) {
Assert.notNull(conversationId);
final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction(
conversationId, null, ringtoneUri, null);
action.start();
}
/**
* Enable/disable vibrations for conversation notification.
*/
public static void enableVibrationForConversationNotification(final String conversationId,
final boolean enableVibration) {
Assert.notNull(conversationId);
final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction(
conversationId, null, null, enableVibration);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
// Keys for all settable settings.
private static final String KEY_ENABLE_NOTIFICATION = "enable_notification";
private static final String KEY_RINGTONE_URI = "ringtone_uri";
private static final String KEY_ENABLE_VIBRATION = "enable_vibration";
protected UpdateConversationOptionsAction(final String conversationId,
final Boolean enableNotification, final String ringtoneUri,
final Boolean enableVibration) {
Assert.notNull(conversationId);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
if (enableNotification != null) {
actionParameters.putBoolean(KEY_ENABLE_NOTIFICATION, enableNotification);
}
if (ringtoneUri != null) {
actionParameters.putString(KEY_RINGTONE_URI, ringtoneUri);
}
if (enableVibration != null) {
actionParameters.putBoolean(KEY_ENABLE_VIBRATION, enableVibration);
}
}
protected void putOptionValuesInTransaction(final ContentValues values,
final DatabaseWrapper dbWrapper) {
Assert.isTrue(dbWrapper.getDatabase().inTransaction());
if (actionParameters.containsKey(KEY_ENABLE_NOTIFICATION)) {
values.put(ConversationColumns.NOTIFICATION_ENABLED,
actionParameters.getBoolean(KEY_ENABLE_NOTIFICATION));
}
if (actionParameters.containsKey(KEY_RINGTONE_URI)) {
values.put(ConversationColumns.NOTIFICATION_SOUND_URI,
actionParameters.getString(KEY_RINGTONE_URI));
}
if (actionParameters.containsKey(KEY_ENABLE_VIBRATION)) {
values.put(ConversationColumns.NOTIFICATION_VIBRATION,
actionParameters.getBoolean(KEY_ENABLE_VIBRATION));
}
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
putOptionValuesInTransaction(values, db);
BugleDatabaseOperations.updateConversationRowIfExists(db, conversationId, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
return null;
}
protected UpdateConversationOptionsAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateConversationOptionsAction> CREATOR
= new Parcelable.Creator<UpdateConversationOptionsAction>() {
@Override
public UpdateConversationOptionsAction createFromParcel(final Parcel in) {
return new UpdateConversationOptionsAction(in);
}
@Override
public UpdateConversationOptionsAction[] newArray(final int size) {
return new UpdateConversationOptionsAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,148 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.util.Assert;
public class UpdateDestinationBlockedAction extends Action {
public interface UpdateDestinationBlockedActionListener {
@Assert.RunsOnMainThread
abstract void onUpdateDestinationBlockedAction(final UpdateDestinationBlockedAction action,
final boolean success,
final boolean block,
final String destination);
}
public static class UpdateDestinationBlockedActionMonitor extends ActionMonitor
implements ActionMonitor.ActionCompletedListener {
private final UpdateDestinationBlockedActionListener mListener;
public UpdateDestinationBlockedActionMonitor(
Object data, UpdateDestinationBlockedActionListener mListener) {
super(STATE_CREATED, generateUniqueActionKey("UpdateDestinationBlockedAction"), data);
setCompletedListener(this);
this.mListener = mListener;
}
private void onActionDone(final boolean succeeded,
final ActionMonitor monitor,
final Action action,
final Object data,
final Object result) {
mListener.onUpdateDestinationBlockedAction(
(UpdateDestinationBlockedAction) action,
succeeded,
action.actionParameters.getBoolean(KEY_BLOCKED),
action.actionParameters.getString(KEY_DESTINATION));
}
@Override
public void onActionSucceeded(final ActionMonitor monitor,
final Action action,
final Object data,
final Object result) {
onActionDone(true, monitor, action, data, result);
}
@Override
public void onActionFailed(final ActionMonitor monitor,
final Action action,
final Object data,
final Object result) {
onActionDone(false, monitor, action, data, result);
}
}
public static UpdateDestinationBlockedActionMonitor updateDestinationBlocked(
final String destination, final boolean blocked, final String conversationId,
final UpdateDestinationBlockedActionListener listener) {
Assert.notNull(listener);
final UpdateDestinationBlockedActionMonitor monitor =
new UpdateDestinationBlockedActionMonitor(null, listener);
final UpdateDestinationBlockedAction action =
new UpdateDestinationBlockedAction(destination, blocked, conversationId,
monitor.getActionKey());
action.start(monitor);
return monitor;
}
private static final String KEY_CONVERSATION_ID = "conversation_id";
private static final String KEY_DESTINATION = "destination";
private static final String KEY_BLOCKED = "blocked";
protected UpdateDestinationBlockedAction(
final String destination, final boolean blocked, final String conversationId,
final String actionKey) {
super(actionKey);
Assert.isTrue(!TextUtils.isEmpty(destination));
actionParameters.putString(KEY_DESTINATION, destination);
actionParameters.putBoolean(KEY_BLOCKED, blocked);
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String destination = actionParameters.getString(KEY_DESTINATION);
final boolean isBlocked = actionParameters.getBoolean(KEY_BLOCKED);
String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final DatabaseWrapper db = DataModel.get().getDatabase();
BugleDatabaseOperations.updateDestination(db, destination, isBlocked);
if (conversationId == null) {
conversationId = BugleDatabaseOperations
.getConversationFromOtherParticipantDestination(db, destination);
}
if (conversationId != null) {
if (isBlocked) {
UpdateConversationArchiveStatusAction.archiveConversation(conversationId);
} else {
UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId);
}
MessagingContentProvider.notifyParticipantsChanged(conversationId);
}
return null;
}
protected UpdateDestinationBlockedAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateDestinationBlockedAction> CREATOR
= new Parcelable.Creator<UpdateDestinationBlockedAction>() {
@Override
public UpdateDestinationBlockedAction createFromParcel(final Parcel in) {
return new UpdateDestinationBlockedAction(in);
}
@Override
public UpdateDestinationBlockedAction[] newArray(final int size) {
return new UpdateDestinationBlockedAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleNotifications;
/**
* Updates the message notification (generally, to include voice replies we've
* made since the notification was first posted).
*/
public class UpdateMessageNotificationAction extends Action {
public static void updateMessageNotification() {
new UpdateMessageNotificationAction().start();
}
private UpdateMessageNotificationAction() {
}
@Override
protected Object executeAction() {
BugleNotifications.update(true /* silent */, BugleNotifications.UPDATE_MESSAGES);
return null;
}
private UpdateMessageNotificationAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateMessageNotificationAction> CREATOR
= new Parcelable.Creator<UpdateMessageNotificationAction>() {
@Override
public UpdateMessageNotificationAction createFromParcel(final Parcel in) {
return new UpdateMessageNotificationAction(in);
}
@Override
public UpdateMessageNotificationAction[] newArray(final int size) {
return new UpdateMessageNotificationAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,103 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.DatabaseHelper.PartColumns;
import com.android.messaging.util.Assert;
/**
* Action used to update size fields of a single part
*/
public class UpdateMessagePartSizeAction extends Action implements Parcelable {
/**
* Update size of part
*/
public static void updateSize(final String partId, final int width, final int height) {
Assert.notNull(partId);
Assert.inRange(width, 0, Integer.MAX_VALUE);
Assert.inRange(height, 0, Integer.MAX_VALUE);
final UpdateMessagePartSizeAction action = new UpdateMessagePartSizeAction(
partId, width, height);
action.start();
}
private static final String KEY_PART_ID = "part_id";
private static final String KEY_WIDTH = "width";
private static final String KEY_HEIGHT = "height";
private UpdateMessagePartSizeAction(final String partId, final int width, final int height) {
actionParameters.putString(KEY_PART_ID, partId);
actionParameters.putInt(KEY_WIDTH, width);
actionParameters.putInt(KEY_HEIGHT, height);
}
@Override
protected Object executeAction() {
final String partId = actionParameters.getString(KEY_PART_ID);
final int width = actionParameters.getInt(KEY_WIDTH);
final int height = actionParameters.getInt(KEY_HEIGHT);
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
final ContentValues values = new ContentValues();
values.put(PartColumns.WIDTH, width);
values.put(PartColumns.HEIGHT, height);
// Part may have been deleted so allow update to fail without asserting
BugleDatabaseOperations.updateRowIfExists(db, DatabaseHelper.PARTS_TABLE,
PartColumns._ID, partId, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return null;
}
private UpdateMessagePartSizeAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<UpdateMessagePartSizeAction> CREATOR
= new Parcelable.Creator<UpdateMessagePartSizeAction>() {
@Override
public UpdateMessagePartSizeAction createFromParcel(final Parcel in) {
return new UpdateMessagePartSizeAction(in);
}
@Override
public UpdateMessagePartSizeAction[] newArray(final int size) {
return new UpdateMessagePartSizeAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}
@@ -0,0 +1,104 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.messaging.datamodel.action;
import android.os.Parcel;
import android.os.Parcelable;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.util.LogUtil;
public class WriteDraftMessageAction extends Action implements Parcelable {
private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
/**
* Set draft message (no listener)
*/
public static void writeDraftMessage(final String conversationId, final MessageData message) {
final WriteDraftMessageAction action = new WriteDraftMessageAction(conversationId, message);
action.start();
}
private static final String KEY_CONVERSATION_ID = "conversationId";
private static final String KEY_MESSAGE = "message";
private WriteDraftMessageAction(final String conversationId, final MessageData message) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
actionParameters.putParcelable(KEY_MESSAGE, message);
}
@Override
protected Object executeAction() {
final DatabaseWrapper db = DataModel.get().getDatabase();
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
final MessageData message = actionParameters.getParcelable(KEY_MESSAGE);
if (message.getSelfId() == null || message.getParticipantId() == null) {
// This could happen when this occurs before the draft message is loaded
// In this case, we just use the conversation's current self id as draft's
// self id and/or participant id
final ConversationListItemData conversation =
ConversationListItemData.getExistingConversation(db, conversationId);
if (conversation != null) {
final String senderAndSelf = conversation.getSelfId();
if (message.getSelfId() == null) {
message.bindSelfId(senderAndSelf);
}
if (message.getParticipantId() == null) {
message.bindParticipantId(senderAndSelf);
}
} else {
LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId +
"already deleted before saving draft message " +
message.getMessageId() + ". Aborting WriteDraftMessageAction.");
return null;
}
}
// Drafts are only kept in the local DB...
final String messageId = BugleDatabaseOperations.updateDraftMessageData(
db, conversationId, message, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT);
MessagingContentProvider.notifyConversationListChanged();
MessagingContentProvider.notifyConversationMetadataChanged(conversationId);
return messageId;
}
private WriteDraftMessageAction(final Parcel in) {
super(in);
}
public static final Parcelable.Creator<WriteDraftMessageAction> CREATOR
= new Parcelable.Creator<WriteDraftMessageAction>() {
@Override
public WriteDraftMessageAction createFromParcel(final Parcel in) {
return new WriteDraftMessageAction(in);
}
@Override
public WriteDraftMessageAction[] newArray(final int size) {
return new WriteDraftMessageAction[size];
}
};
@Override
public void writeToParcel(final Parcel parcel, final int flags) {
writeActionToParcel(parcel, flags);
}
}