package com.zegoggles.smssync.service;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.provider.CallLog;
import android.provider.Telephony;
import android.util.Log;
import com.fsck.k9.mail.AuthenticationFailedException;
import com.fsck.k9.mail.FetchProfile;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.store.imap.XOAuth2AuthenticationFailedException;
import com.squareup.otto.Subscribe;
import com.zegoggles.smssync.App;
import com.zegoggles.smssync.Consts;
import com.zegoggles.smssync.auth.TokenRefreshException;
import com.zegoggles.smssync.auth.TokenRefresher;
import com.zegoggles.smssync.mail.BackupImapStore;
import com.zegoggles.smssync.mail.DataType;
import com.zegoggles.smssync.mail.MessageConverter;
import com.zegoggles.smssync.service.state.RestoreState;
import com.zegoggles.smssync.service.state.SmsSyncState;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static com.zegoggles.smssync.App.LOCAL_LOGV;
import static com.zegoggles.smssync.App.TAG;
import static com.zegoggles.smssync.mail.DataType.CALLLOG;
import static com.zegoggles.smssync.mail.DataType.SMS;
import static com.zegoggles.smssync.service.state.SmsSyncState.CALC;
import static com.zegoggles.smssync.service.state.SmsSyncState.CANCELED_RESTORE;
import static com.zegoggles.smssync.service.state.SmsSyncState.ERROR;
import static com.zegoggles.smssync.service.state.SmsSyncState.FINISHED_RESTORE;
import static com.zegoggles.smssync.service.state.SmsSyncState.LOGIN;
import static com.zegoggles.smssync.service.state.SmsSyncState.RESTORE;
import static com.zegoggles.smssync.service.state.SmsSyncState.UPDATING_THREADS;
class RestoreTask extends AsyncTask<RestoreConfig, RestoreState, RestoreState> {
private static final String ERROR = "error";
private Set<String> smsIds = new HashSet<String>();
private Set<String> callLogIds = new HashSet<String>();
private Set<String> uids = new HashSet<String>();
private final SmsRestoreService service;
private final ContentResolver resolver;
private final MessageConverter converter;
private final TokenRefresher tokenRefresher;
public RestoreTask(SmsRestoreService service,
MessageConverter converter,
ContentResolver resolver,
TokenRefresher tokenRefresher) {
this.service = service;
this.converter = converter;
this.resolver = resolver;
this.tokenRefresher = tokenRefresher;
}
@Override
protected void onPreExecute() {
App.bus.register(this);
}
@Subscribe public void userCanceled(UserCanceled canceled) {
cancel(false);
}
@NotNull protected RestoreState doInBackground(RestoreConfig... params) {
if (params == null || params.length == 0) throw new IllegalArgumentException("No config passed");
RestoreConfig config = params[0];
if (!config.restoreSms && !config.restoreCallLog) {
return new RestoreState(FINISHED_RESTORE, 0, 0, 0, 0, null, null);
} else {
try {
service.acquireLocks();
return restore(config);
} finally {
service.releaseLocks();
}
}
}
private RestoreState restore(RestoreConfig config) {
final BackupImapStore imapStore = config.imapStore;
int currentRestoredItem = config.currentRestoredItem;
try {
publishProgress(LOGIN);
imapStore.checkSettings();
publishProgress(CALC);
final List<Message> msgs = new ArrayList<Message>();
if (config.restoreSms) {
msgs.addAll(imapStore.getFolder(SMS).getMessages(config.maxRestore, config.restoreOnlyStarred, null));
}
if (config.restoreCallLog) {
msgs.addAll(imapStore.getFolder(CALLLOG).getMessages(config.maxRestore, config.restoreOnlyStarred, null));
}
final int itemsToRestoreCount = config.maxRestore <= 0 ? msgs.size() : Math.min(msgs.size(), config.maxRestore);
if (itemsToRestoreCount > 0) {
for (; currentRestoredItem < itemsToRestoreCount && !isCancelled(); currentRestoredItem++) {
DataType dataType = importMessage(msgs.get(currentRestoredItem));
msgs.set(currentRestoredItem, null); // help gc
publishProgress(new RestoreState(RESTORE, currentRestoredItem, itemsToRestoreCount, 0, 0, dataType, null));
if (currentRestoredItem % 50 == 0) {
//clear cache periodically otherwise SD card fills up
service.clearCache();
}
}
updateAllThreadsIfAnySmsRestored();
} else {
Log.d(TAG, "nothing to restore");
}
final int restoredCount = smsIds.size() + callLogIds.size();
return new RestoreState(isCancelled() ? CANCELED_RESTORE : FINISHED_RESTORE,
currentRestoredItem,
itemsToRestoreCount,
restoredCount,
uids.size() - restoredCount, null, null);
} catch (XOAuth2AuthenticationFailedException e) {
return handleAuthError(config, currentRestoredItem, e);
} catch (AuthenticationFailedException e) {
return transition(SmsSyncState.ERROR, e);
} catch (MessagingException e) {
Log.e(TAG, ERROR, e);
updateAllThreadsIfAnySmsRestored();
return transition(SmsSyncState.ERROR, e);
} catch (IllegalStateException e) {
// usually memory problems (Couldn't init cursor window)
return transition(SmsSyncState.ERROR, e);
} finally {
imapStore.closeFolders();
}
}
private RestoreState handleAuthError(RestoreConfig config, int currentRestoredItem, XOAuth2AuthenticationFailedException e) {
if (e.getStatus() == 400) {
Log.d(TAG, "need to perform xoauth2 token refresh");
if (config.tries < 1) {
try {
tokenRefresher.refreshOAuth2Token();
// we got a new token, let's retry one more time - we need to pass in a new store object
// since the auth params on it are immutable
return restore(config.retryWithStore(currentRestoredItem, service.getBackupImapStore()));
} catch (MessagingException ignored) {
Log.w(TAG, ignored);
} catch (TokenRefreshException refreshException) {
Log.w(TAG, refreshException);
}
} else {
Log.w(TAG, "no new token obtained, giving up");
}
} else {
Log.w(TAG, "unexpected xoauth status code " + e.getStatus());
}
return transition(SmsSyncState.ERROR, e);
}
private void publishProgress(SmsSyncState smsSyncState) {
publishProgress(smsSyncState, null);
}
private void publishProgress(SmsSyncState smsSyncState, Exception exception) {
publishProgress(transition(smsSyncState, exception));
}
private RestoreState transition(SmsSyncState smsSyncState, Exception exception) {
return service.getState().transition(smsSyncState, exception);
}
@Override
protected void onPostExecute(RestoreState result) {
if (result != null) {
Log.d(TAG, "finished (" + result + "/" + uids.size() + ")");
post(result);
}
App.bus.unregister(this);
}
@Override
protected void onCancelled() {
Log.d(TAG, "restore canceled by user");
post(transition(CANCELED_RESTORE, null));
App.bus.unregister(this);
}
@Override
protected void onProgressUpdate(RestoreState... progress) {
if (progress != null && progress.length > 0 && !isCancelled()) {
post(progress[0]);
}
}
private void post(RestoreState changed) {
if (changed == null) return;
App.bus.post(changed);
}
private DataType importMessage(Message message) {
uids.add(message.getUid());
FetchProfile fp = new FetchProfile();
fp.add(FetchProfile.Item.BODY);
DataType dataType = null;
try {
if (LOCAL_LOGV) Log.v(TAG, "fetching message uid " + message.getUid());
message.getFolder().fetch(Arrays.asList(message), fp, null);
dataType = converter.getDataType(message);
//only restore sms+call log for now
switch (dataType) {
case CALLLOG:
importCallLog(message);
break;
case SMS:
importSms(message);
break;
default:
if (LOCAL_LOGV) Log.d(TAG, "ignoring restore of type: " + dataType);
}
} catch (MessagingException e) {
Log.e(TAG, ERROR, e);
} catch (IllegalArgumentException e) {
// http://code.google.com/p/android/issues/detail?id=2916
Log.e(TAG, ERROR, e);
} catch (IOException e) {
Log.e(TAG, ERROR, e);
}
return dataType;
}
private void importSms(final Message message) throws IOException, MessagingException {
if (LOCAL_LOGV) Log.v(TAG, "importSms(" + message + ")");
final ContentValues values = converter.messageToContentValues(message);
final Integer type = values.getAsInteger(Telephony.TextBasedSmsColumns.TYPE);
// only restore inbox messages and sent messages - otherwise sms might get sent on restore
if (type != null && (type == Telephony.TextBasedSmsColumns.MESSAGE_TYPE_INBOX || type == Telephony.TextBasedSmsColumns.MESSAGE_TYPE_SENT) && !smsExists(values)) {
final Uri uri = resolver.insert(Consts.SMS_PROVIDER, values);
if (uri != null) {
smsIds.add(uri.getLastPathSegment());
Long timestamp = values.getAsLong(Telephony.TextBasedSmsColumns.DATE);
if (timestamp != null && SMS.getMaxSyncedDate(service) < timestamp) {
SMS.setMaxSyncedDate(service, timestamp);
}
if (LOCAL_LOGV) Log.v(TAG, "inserted " + uri);
}
} else {
if (LOCAL_LOGV) Log.d(TAG, "ignoring sms");
}
}
private void importCallLog(final Message message) throws MessagingException, IOException {
if (LOCAL_LOGV) Log.v(TAG, "importCallLog(" + message + ")");
final ContentValues values = converter.messageToContentValues(message);
if (!callLogExists(values)) {
final Uri uri = resolver.insert(Consts.CALLLOG_PROVIDER, values);
if (uri != null) callLogIds.add(uri.getLastPathSegment());
} else {
if (LOCAL_LOGV) Log.d(TAG, "ignoring call log");
}
}
private boolean callLogExists(ContentValues values) {
Cursor c = resolver.query(Consts.CALLLOG_PROVIDER,
new String[] { "_id" },
"date = ? AND number = ? AND duration = ? AND type = ?",
new String[]{
values.getAsString(CallLog.Calls.DATE),
values.getAsString(CallLog.Calls.NUMBER),
values.getAsString(CallLog.Calls.DURATION),
values.getAsString(CallLog.Calls.TYPE)
},
null
);
boolean exists = false;
if (c != null) {
exists = c.getCount() > 0;
c.close();
}
return exists;
}
private boolean smsExists(ContentValues values) {
// just assume equality on date+address+type
Cursor c = resolver.query(Consts.SMS_PROVIDER,
new String[] {"_id" },
"date = ? AND address = ? AND type = ?",
new String[] {
values.getAsString(Telephony.TextBasedSmsColumns.DATE),
values.getAsString(Telephony.TextBasedSmsColumns.ADDRESS),
values.getAsString(Telephony.TextBasedSmsColumns.TYPE)
},
null
);
boolean exists = false;
if (c != null) {
exists = c.getCount() > 0;
c.close();
}
return exists;
}
private void updateAllThreadsIfAnySmsRestored() {
if (smsIds.size() > 0) {
updateAllThreads();
}
}
private void updateAllThreads() {
// thread dates + states might be wrong, we need to force a full update
// unfortunately there's no direct way to do that in the SDK, but passing a
// negative conversation id to delete should to the trick
publishProgress(UPDATING_THREADS);
Log.d(TAG, "updating threads");
resolver.delete(Uri.parse("content://sms/conversations/-1"), null, null);
Log.d(TAG, "finished");
}
protected Set<String> getSmsIds() {
return smsIds;
}
}