package com.fsck.k9.mail.store.imap;
import java.io.IOException;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import android.content.Context;
import android.os.PowerManager;
import com.fsck.k9.mail.AuthenticationFailedException;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.K9MailLib;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.PushReceiver;
import com.fsck.k9.mail.power.TracingPowerManager;
import com.fsck.k9.mail.power.TracingPowerManager.TracingWakeLock;
import com.fsck.k9.mail.store.RemoteStore;
import timber.log.Timber;
import static com.fsck.k9.mail.K9MailLib.PUSH_WAKE_LOCK_TIMEOUT;
import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase;
class ImapFolderPusher extends ImapFolder {
private static final int IDLE_READ_TIMEOUT_INCREMENT = 5 * 60 * 1000;
private static final int IDLE_FAILURE_COUNT_LIMIT = 10;
private static final int MAX_DELAY_TIME = 5 * 60 * 1000; // 5 minutes
private static final int NORMAL_DELAY_TIME = 5000;
private final PushReceiver pushReceiver;
private final Object threadLock = new Object();
private final IdleStopper idleStopper = new IdleStopper();
private final TracingWakeLock wakeLock;
private final List<ImapResponse> storedUntaggedResponses = new ArrayList<ImapResponse>();
private Thread listeningThread;
private volatile boolean stop = false;
private volatile boolean idling = false;
public ImapFolderPusher(ImapStore store, String name, PushReceiver pushReceiver) {
super(store, name);
this.pushReceiver = pushReceiver;
Context context = pushReceiver.getContext();
TracingPowerManager powerManager = TracingPowerManager.getPowerManager(context);
String tag = "ImapFolderPusher " + store.getStoreConfig().toString() + ":" + getName();
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag);
wakeLock.setReferenceCounted(false);
}
public void start() {
synchronized (threadLock) {
if (listeningThread != null) {
throw new IllegalStateException("start() called twice");
}
listeningThread = new Thread(new PushRunnable());
listeningThread.start();
}
}
public void refresh() throws IOException, MessagingException {
if (idling) {
wakeLock.acquire(PUSH_WAKE_LOCK_TIMEOUT);
idleStopper.stopIdle();
}
}
public void stop() {
synchronized (threadLock) {
if (listeningThread == null) {
throw new IllegalStateException("stop() called twice");
}
stop = true;
listeningThread.interrupt();
listeningThread = null;
}
ImapConnection conn = connection;
if (conn != null) {
if (K9MailLib.isDebug()) {
Timber.v("Closing connection to stop pushing for %s", getLogId());
}
conn.close();
} else {
Timber.w("Attempt to interrupt null connection to stop pushing on folderPusher for %s", getLogId());
}
}
@Override
protected void handleUntaggedResponse(ImapResponse response) {
if (response.getTag() == null && response.size() > 1) {
Object responseType = response.get(1);
if (equalsIgnoreCase(responseType, "FETCH") || equalsIgnoreCase(responseType, "EXPUNGE") ||
equalsIgnoreCase(responseType, "EXISTS")) {
if (K9MailLib.isDebug()) {
Timber.d("Storing response %s for later processing", response);
}
synchronized (storedUntaggedResponses) {
storedUntaggedResponses.add(response);
}
}
handlePossibleUidNext(response);
}
}
private void superHandleUntaggedResponse(ImapResponse response) {
super.handleUntaggedResponse(response);
}
private class PushRunnable implements Runnable, UntaggedHandler {
private int delayTime = NORMAL_DELAY_TIME;
private int idleFailureCount = 0;
private boolean needsPoll = false;
@Override
public void run() {
wakeLock.acquire(PUSH_WAKE_LOCK_TIMEOUT);
if (K9MailLib.isDebug()) {
Timber.i("Pusher starting for %s", getLogId());
}
long lastUidNext = -1L;
while (!stop) {
try {
long oldUidNext = getOldUidNext();
/*
* This makes sure 'oldUidNext' is never smaller than 'UIDNEXT' from
* the last loop iteration. This way we avoid looping endlessly causing
* the battery to drain.
*
* See issue 4907
*/
if (oldUidNext < lastUidNext) {
oldUidNext = lastUidNext;
}
boolean openedNewConnection = openConnectionIfNecessary();
if (stop) {
break;
}
boolean pushPollOnConnect = store.getStoreConfig().isPushPollOnConnect();
if (pushPollOnConnect && (openedNewConnection || needsPoll)) {
needsPoll = false;
syncFolderOnConnect();
}
if (stop) {
break;
}
long newUidNext = getNewUidNext();
lastUidNext = newUidNext;
long startUid = getStartUid(oldUidNext, newUidNext);
if (newUidNext > startUid) {
notifyMessagesArrived(startUid, newUidNext);
} else {
processStoredUntaggedResponses();
if (K9MailLib.isDebug()) {
Timber.i("About to IDLE for %s", getLogId());
}
prepareForIdle();
ImapConnection conn = connection;
setReadTimeoutForIdle(conn);
sendIdle(conn);
returnFromIdle();
}
} catch (AuthenticationFailedException e) {
reacquireWakeLockAndCleanUp();
if (K9MailLib.isDebug()) {
Timber.e(e, "Authentication failed. Stopping ImapFolderPusher.");
}
pushReceiver.authenticationFailed();
stop = true;
} catch (Exception e) {
reacquireWakeLockAndCleanUp();
if (stop) {
Timber.i("Got exception while idling, but stop is set for %s", getLogId());
} else {
pushReceiver.pushError("Push error for " + getName(), e);
Timber.e("Got exception while idling for %s", getLogId());
pushReceiver.sleep(wakeLock, delayTime);
delayTime *= 2;
if (delayTime > MAX_DELAY_TIME) {
delayTime = MAX_DELAY_TIME;
}
idleFailureCount++;
if (idleFailureCount > IDLE_FAILURE_COUNT_LIMIT) {
Timber.e("Disabling pusher for %s after %d consecutive errors", getLogId(), idleFailureCount);
pushReceiver.pushError("Push disabled for " + getName() + " after " + idleFailureCount +
" consecutive errors", e);
stop = true;
}
}
}
}
pushReceiver.setPushActive(getName(), false);
try {
if (K9MailLib.isDebug()) {
Timber.i("Pusher for %s is exiting", getLogId());
}
close();
} catch (Exception me) {
Timber.e(me, "Got exception while closing for %s", getLogId());
} finally {
wakeLock.release();
}
}
private void reacquireWakeLockAndCleanUp() {
wakeLock.acquire(PUSH_WAKE_LOCK_TIMEOUT);
clearStoredUntaggedResponses();
idling = false;
pushReceiver.setPushActive(getName(), false);
try {
connection.close();
} catch (Exception me) {
Timber.e(me, "Got exception while closing for exception for %s", getLogId());
}
connection = null;
}
private long getNewUidNext() throws MessagingException {
long newUidNext = uidNext;
if (newUidNext != -1L) {
return newUidNext;
}
if (K9MailLib.isDebug()) {
Timber.d("uidNext is -1, using search to find highest UID");
}
long highestUid = getHighestUid();
if (highestUid == -1L) {
return -1L;
}
newUidNext = highestUid + 1;
if (K9MailLib.isDebug()) {
Timber.d("highest UID = %d, set newUidNext to %d", highestUid, newUidNext);
}
return newUidNext;
}
private long getStartUid(long oldUidNext, long newUidNext) {
long startUid = oldUidNext;
int displayCount = store.getStoreConfig().getDisplayCount();
if (startUid < newUidNext - displayCount) {
startUid = newUidNext - displayCount;
}
if (startUid < 1) {
startUid = 1;
}
return startUid;
}
private void prepareForIdle() {
pushReceiver.setPushActive(getName(), true);
idling = true;
}
private void sendIdle(ImapConnection conn) throws MessagingException, IOException {
String tag = conn.sendCommand(Commands.IDLE, false);
List<ImapResponse> responses;
try {
try {
responses = conn.readStatusResponse(tag, Commands.IDLE, this);
} finally {
idleStopper.stopAcceptingDoneContinuation();
}
} catch (IOException e) {
conn.close();
throw e;
}
handleUntaggedResponses(responses);
}
private void returnFromIdle() {
idling = false;
delayTime = NORMAL_DELAY_TIME;
idleFailureCount = 0;
}
private boolean openConnectionIfNecessary() throws MessagingException {
ImapConnection oldConnection = connection;
internalOpen(OPEN_MODE_RO);
ImapConnection conn = connection;
checkConnectionNotNull(conn);
checkConnectionIdleCapable(conn);
return conn != oldConnection;
}
private void checkConnectionNotNull(ImapConnection conn) throws MessagingException {
if (conn == null) {
String message = "Could not establish connection for IDLE";
pushReceiver.pushError(message, null);
throw new MessagingException(message);
}
}
private void checkConnectionIdleCapable(ImapConnection conn) throws MessagingException {
if (!conn.isIdleCapable()) {
stop = true;
String message = "IMAP server is not IDLE capable: " + conn.toString();
pushReceiver.pushError(message, null);
throw new MessagingException(message);
}
}
private void setReadTimeoutForIdle(ImapConnection conn) throws SocketException {
int idleRefreshTimeout = store.getStoreConfig().getIdleRefreshMinutes() * 60 * 1000;
conn.setReadTimeout(idleRefreshTimeout + IDLE_READ_TIMEOUT_INCREMENT);
}
@Override
public void handleAsyncUntaggedResponse(ImapResponse response) {
if (K9MailLib.isDebug()) {
Timber.v("Got async response: %s", response);
}
if (stop) {
if (K9MailLib.isDebug()) {
Timber.d("Got async untagged response: %s, but stop is set for %s", response, getLogId());
}
idleStopper.stopIdle();
} else {
if (response.getTag() == null) {
if (response.size() > 1) {
Object responseType = response.get(1);
if (equalsIgnoreCase(responseType, "EXISTS") || equalsIgnoreCase(responseType, "EXPUNGE") ||
equalsIgnoreCase(responseType, "FETCH")) {
wakeLock.acquire(PUSH_WAKE_LOCK_TIMEOUT);
if (K9MailLib.isDebug()) {
Timber.d("Got useful async untagged response: %s for %s", response, getLogId());
}
idleStopper.stopIdle();
}
} else if (response.isContinuationRequested()) {
if (K9MailLib.isDebug()) {
Timber.d("Idling %s", getLogId());
}
idleStopper.startAcceptingDoneContinuation(connection);
wakeLock.release();
}
}
}
}
private void clearStoredUntaggedResponses() {
synchronized (storedUntaggedResponses) {
storedUntaggedResponses.clear();
}
}
private void processStoredUntaggedResponses() throws MessagingException {
while (true) {
List<ImapResponse> untaggedResponses = getAndClearStoredUntaggedResponses();
if (untaggedResponses.isEmpty()) {
break;
}
if (K9MailLib.isDebug()) {
Timber.i("Processing %d untagged responses from previous commands for %s",
untaggedResponses.size(), getLogId());
}
processUntaggedResponses(untaggedResponses);
}
}
private List<ImapResponse> getAndClearStoredUntaggedResponses() {
synchronized (storedUntaggedResponses) {
if (storedUntaggedResponses.isEmpty()) {
return Collections.emptyList();
}
List<ImapResponse> untaggedResponses = new ArrayList<ImapResponse>(storedUntaggedResponses);
storedUntaggedResponses.clear();
return untaggedResponses;
}
}
private void processUntaggedResponses(List<ImapResponse> responses) throws MessagingException {
boolean skipSync = false;
int oldMessageCount = messageCount;
if (oldMessageCount == -1) {
skipSync = true;
}
List<Long> flagSyncMsgSeqs = new ArrayList<Long>();
List<String> removeMsgUids = new LinkedList<String>();
for (ImapResponse response : responses) {
oldMessageCount += processUntaggedResponse(oldMessageCount, response, flagSyncMsgSeqs, removeMsgUids);
}
if (!skipSync) {
if (oldMessageCount < 0) {
oldMessageCount = 0;
}
if (messageCount > oldMessageCount) {
syncMessages(messageCount);
}
}
if (K9MailLib.isDebug()) {
Timber.d("UIDs for messages needing flag sync are %s for %s", flagSyncMsgSeqs, getLogId());
}
if (!flagSyncMsgSeqs.isEmpty()) {
syncMessages(flagSyncMsgSeqs);
}
if (!removeMsgUids.isEmpty()) {
removeMessages(removeMsgUids);
}
}
private int processUntaggedResponse(long oldMessageCount, ImapResponse response, List<Long> flagSyncMsgSeqs,
List<String> removeMsgUids) {
superHandleUntaggedResponse(response);
int messageCountDelta = 0;
if (response.getTag() == null && response.size() > 1) {
try {
Object responseType = response.get(1);
if (equalsIgnoreCase(responseType, "FETCH")) {
Timber.i("Got FETCH %s", response);
long msgSeq = response.getLong(0);
if (K9MailLib.isDebug()) {
Timber.d("Got untagged FETCH for msgseq %d for %s", msgSeq, getLogId());
}
if (!flagSyncMsgSeqs.contains(msgSeq)) {
flagSyncMsgSeqs.add(msgSeq);
}
}
if (equalsIgnoreCase(responseType, "EXPUNGE")) {
long msgSeq = response.getLong(0);
if (msgSeq <= oldMessageCount) {
messageCountDelta = -1;
}
if (K9MailLib.isDebug()) {
Timber.d("Got untagged EXPUNGE for msgseq %d for %s", msgSeq, getLogId());
}
List<Long> newSeqs = new ArrayList<Long>();
Iterator<Long> flagIter = flagSyncMsgSeqs.iterator();
while (flagIter.hasNext()) {
long flagMsg = flagIter.next();
if (flagMsg >= msgSeq) {
flagIter.remove();
if (flagMsg > msgSeq) {
newSeqs.add(flagMsg);
}
}
}
flagSyncMsgSeqs.addAll(newSeqs);
List<Long> msgSeqs = new ArrayList<Long>(msgSeqUidMap.keySet());
Collections.sort(msgSeqs); // Have to do comparisons in order because of msgSeq reductions
for (long msgSeqNum : msgSeqs) {
if (K9MailLib.isDebug()) {
Timber.v("Comparing EXPUNGEd msgSeq %d to %d", msgSeq, msgSeqNum);
}
if (msgSeqNum == msgSeq) {
String uid = msgSeqUidMap.get(msgSeqNum);
if (K9MailLib.isDebug()) {
Timber.d("Scheduling removal of UID %s because msgSeq %d was expunged", uid, msgSeqNum);
}
removeMsgUids.add(uid);
msgSeqUidMap.remove(msgSeqNum);
} else if (msgSeqNum > msgSeq) {
String uid = msgSeqUidMap.get(msgSeqNum);
if (K9MailLib.isDebug()) {
Timber.d("Reducing msgSeq for UID %s from %d to %d", uid, msgSeqNum, (msgSeqNum - 1));
}
msgSeqUidMap.remove(msgSeqNum);
msgSeqUidMap.put(msgSeqNum - 1, uid);
}
}
}
} catch (Exception e) {
Timber.e(e, "Could not handle untagged FETCH for %s", getLogId());
}
}
return messageCountDelta;
}
private void syncMessages(int end) throws MessagingException {
long oldUidNext = getOldUidNext();
List<ImapMessage> messageList = getMessages(end, end, null, true, null);
if (messageList != null && messageList.size() > 0) {
long newUid = Long.parseLong(messageList.get(0).getUid());
if (K9MailLib.isDebug()) {
Timber.i("Got newUid %s for message %d on %s", newUid, end, getLogId());
}
long startUid = oldUidNext;
if (startUid < newUid - 10) {
startUid = newUid - 10;
}
if (startUid < 1) {
startUid = 1;
}
if (newUid >= startUid) {
if (K9MailLib.isDebug()) {
Timber.i("Needs sync from uid %d to %d for %s", startUid, newUid, getLogId());
}
List<Message> messages = new ArrayList<Message>();
for (long uid = startUid; uid <= newUid; uid++) {
ImapMessage message = new ImapMessage(Long.toString(uid), ImapFolderPusher.this);
messages.add(message);
}
if (!messages.isEmpty()) {
pushReceiver.messagesArrived(ImapFolderPusher.this, messages);
}
}
}
}
private void syncMessages(List<Long> flagSyncMsgSeqs) {
try {
List<? extends Message> messageList = getMessages(flagSyncMsgSeqs, true, null);
List<Message> messages = new ArrayList<Message>();
messages.addAll(messageList);
pushReceiver.messagesFlagsChanged(ImapFolderPusher.this, messages);
} catch (Exception e) {
pushReceiver.pushError("Exception while processing Push untagged responses", e);
}
}
private void removeMessages(List<String> removeUids) {
List<Message> messages = new ArrayList<Message>(removeUids.size());
try {
List<ImapMessage> existingMessages = getMessagesFromUids(removeUids);
for (Message existingMessage : existingMessages) {
needsPoll = true;
msgSeqUidMap.clear();
String existingUid = existingMessage.getUid();
Timber.w("Message with UID %s still exists on server, not expunging", existingUid);
removeUids.remove(existingUid);
}
for (String uid : removeUids) {
ImapMessage message = new ImapMessage(uid, ImapFolderPusher.this);
try {
message.setFlagInternal(Flag.DELETED, true);
} catch (MessagingException me) {
Timber.e("Unable to set DELETED flag on message %s", message.getUid());
}
messages.add(message);
}
pushReceiver.messagesRemoved(ImapFolderPusher.this, messages);
} catch (Exception e) {
Timber.e("Cannot remove EXPUNGEd messages");
}
}
private void syncFolderOnConnect() throws MessagingException {
processStoredUntaggedResponses();
if (messageCount == -1) {
throw new MessagingException("Message count = -1 for idling");
}
pushReceiver.syncFolder(ImapFolderPusher.this);
}
private void notifyMessagesArrived(long startUid, long uidNext) {
if (K9MailLib.isDebug()) {
Timber.i("Needs sync from uid %d to %d for %s", startUid, uidNext, getLogId());
}
int count = (int) (uidNext - startUid);
List<Message> messages = new ArrayList<Message>(count);
for (long uid = startUid; uid < uidNext; uid++) {
ImapMessage message = new ImapMessage(Long.toString(uid), ImapFolderPusher.this);
messages.add(message);
}
pushReceiver.messagesArrived(ImapFolderPusher.this, messages);
}
private long getOldUidNext() {
long oldUidNext = -1L;
try {
String serializedPushState = pushReceiver.getPushState(getName());
ImapPushState pushState = ImapPushState.parse(serializedPushState);
oldUidNext = pushState.uidNext;
if (K9MailLib.isDebug()) {
Timber.i("Got oldUidNext %d for %s", oldUidNext, getLogId());
}
} catch (Exception e) {
Timber.e(e, "Unable to get oldUidNext for %s", getLogId());
}
return oldUidNext;
}
}
/**
* Ensure the DONE continuation is only sent when the IDLE command was sent and hasn't completed yet.
*/
private static class IdleStopper {
private boolean acceptDoneContinuation = false;
private ImapConnection imapConnection;
public synchronized void startAcceptingDoneContinuation(ImapConnection connection) {
if (connection == null) {
throw new NullPointerException("connection must not be null");
}
acceptDoneContinuation = true;
imapConnection = connection;
}
public synchronized void stopAcceptingDoneContinuation() {
acceptDoneContinuation = false;
imapConnection = null;
}
public synchronized void stopIdle() {
if (acceptDoneContinuation) {
acceptDoneContinuation = false;
sendDone();
}
}
private void sendDone() {
try {
imapConnection.setReadTimeout(RemoteStore.SOCKET_READ_TIMEOUT);
imapConnection.sendContinuation("DONE");
} catch (IOException e) {
imapConnection.close();
}
}
}
}