package eu.faircode.email; /* This file is part of FairEmail. FairEmail is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. FairEmail is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with FairEmail. If not, see . Copyright 2018-2019 by Marcel Bokhorst (M66B) */ import android.app.AlarmManager; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; import android.net.NetworkRequest; import android.os.Build; import android.os.Handler; import android.os.PowerManager; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import androidx.annotation.Nullable; import androidx.core.app.AlarmManagerCompat; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.lifecycle.Observer; import androidx.preference.PreferenceManager; import com.sun.mail.iap.BadCommandException; import com.sun.mail.imap.IMAPFolder; import java.text.DateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import javax.mail.AuthenticationFailedException; import javax.mail.Folder; import javax.mail.FolderClosedException; import javax.mail.FolderNotFoundException; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.NoSuchProviderException; import javax.mail.ReadOnlyFolderException; import javax.mail.StoreClosedException; import javax.mail.event.FolderAdapter; import javax.mail.event.FolderEvent; import javax.mail.event.MessageChangedEvent; import javax.mail.event.MessageChangedListener; import javax.mail.event.MessageCountAdapter; import javax.mail.event.MessageCountEvent; import javax.mail.event.StoreEvent; import javax.mail.event.StoreListener; import me.leolin.shortcutbadger.ShortcutBadger; import static android.os.Process.THREAD_PRIORITY_BACKGROUND; public class ServiceSynchronize extends ServiceBase { private ConnectionHelper.NetworkState networkState = new ConnectionHelper.NetworkState(); private Core.State state = null; private int lastStartId = -1; private boolean started = false; private int queued = 0; private long lastLost = 0; private TupleAccountStats lastStats = new TupleAccountStats(); private ExecutorService queue = Helper.getBackgroundExecutor(1, "service"); private static boolean sync = true; private static boolean oneshot = false; private static final int CONNECT_BACKOFF_START = 8; // seconds private static final int CONNECT_BACKOFF_MAX = 64; // seconds (totally 2 minutes) private static final int CONNECT_BACKOFF_AlARM = 15; // minutes private static final long RECONNECT_BACKOFF = 90 * 1000L; // milliseconds private static final int ACCOUNT_ERROR_AFTER = 60; // minutes private static final int BACKOFF_ERROR_AFTER = 16; // seconds private static final long ONESHOT_DURATION_RUN = 90 * 1000L; // milliseconds private static final long ONESHOT_DURATION_IDLE = 10 * 1000L; // milliseconds private static final long STOP_DELAY = 5000L; // milliseconds private static final long CHECK_ALIVE_INTERVAL = 19 * 60 * 1000L; // milliseconds static final int PI_ALARM = 1; static final int PI_ONESHOT = 2; @Override public void onCreate() { EntityLog.log(this, "Service create version=" + BuildConfig.VERSION_NAME); super.onCreate(); startForeground(Helper.NOTIFICATION_SYNCHRONIZE, getNotificationService(null).build()); // Listen for network changes ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); NetworkRequest.Builder builder = new NetworkRequest.Builder(); builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); // Removed because of Android VPN service // builder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); cm.registerNetworkCallback(builder.build(), networkCallback); IntentFilter iif = new IntentFilter(); iif.addAction(ConnectivityManager.CONNECTIVITY_ACTION); iif.addAction(Intent.ACTION_AIRPLANE_MODE_CHANGED); registerReceiver(connectionChangedReceiver, iif); registerReceiver(onScreenOff, new IntentFilter(Intent.ACTION_SCREEN_OFF)); DB db = DB.getInstance(this); db.account().liveStats().observe(this, new Observer() { private TupleAccountStats lastStats = null; @Override public void onChanged(@Nullable TupleAccountStats stats) { if (stats != null && !stats.equals(lastStats)) { try { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify(Helper.NOTIFICATION_SYNCHRONIZE, getNotificationService(stats).build()); } catch (Throwable ex) { Log.e(ex); } if (oneshot) onOneshot(true, stats.operations == 0); } lastStats = stats; } }); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); db.message().liveUnseenWidget().observe(this, new Observer() { private Integer lastUnseen = null; @Override public void onChanged(TupleMessageStats stats) { if (stats == null) stats = new TupleMessageStats(); boolean unseen_ignored = prefs.getBoolean("unseen_ignored", false); Integer unseen = (unseen_ignored ? stats.notifying : stats.unseen); if (unseen == null) unseen = 0; if (lastUnseen == null || !lastUnseen.equals(unseen)) { Log.i("Stats " + stats); lastUnseen = unseen; setUnseen(unseen); } } }); final TwoStateOwner cowner = new TwoStateOwner(this, "liveUnseenNotify"); db.folder().liveSynchronizing().observe(this, new Observer() { @Override public void onChanged(Integer count) { Log.i("Synchronizing folders=" + count); if (count == null || count == 0) cowner.start(); else cowner.stop(); } }); Map> groupNotifying = new HashMap<>(); // Get existing notifications if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) try { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); for (StatusBarNotification sbn : nm.getActiveNotifications()) { String tag = sbn.getTag(); if (tag != null && tag.startsWith("unseen.")) { String[] p = tag.split(("\\.")); long group = Long.parseLong(p[1]); long id = sbn.getNotification().extras.getLong("id", 0); if (!groupNotifying.containsKey(group)) groupNotifying.put(group, new ArrayList<>()); if (id > 0) { Log.i("Notify restore " + tag + " id=" + id); groupNotifying.get(group).add(id); } } } } catch (Throwable ex) { Log.w(ex); /* java.lang.RuntimeException: Unable to create service eu.faircode.email.ServiceSynchronize: java.lang.NullPointerException: Attempt to invoke virtual method 'java.util.List android.content.pm.ParceledListSlice.getList()' on a null object reference at android.app.ActivityThread.handleCreateService(ActivityThread.java:2944) at android.app.ActivityThread.access$1900(ActivityThread.java:154) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1474) at android.os.Handler.dispatchMessage(Handler.java:102) at android.os.Looper.loop(Looper.java:234) at android.app.ActivityThread.main(ActivityThread.java:5526) */ } db.message().liveUnseenNotify().observe(cowner, new Observer>() { private ExecutorService executor = Helper.getBackgroundExecutor(1, "notify"); @Override public void onChanged(final List messages) { executor.submit(new Runnable() { @Override public void run() { try { Core.notifyMessages(ServiceSynchronize.this, messages, groupNotifying); } catch (SecurityException ex) { Log.w(ex); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSynchronize.this); prefs.edit().remove("sound").apply(); } catch (Throwable ex) { Log.e(ex); } } }); } }); db.message().liveWidgetUnified().observe(this, new Observer() { private TupleMessageWidgetCount last = null; @Override public void onChanged(TupleMessageWidgetCount current) { Log.i("Widget total=" + current.total + " seen=" + current.seen + " flagged=" + current.flagged); if (last == null || !last.equals(current)) WidgetUnified.update(ServiceSynchronize.this); last = current; } }); } @Override public void onDestroy() { EntityLog.log(this, "Service destroy"); unregisterReceiver(onScreenOff); unregisterReceiver(connectionChangedReceiver); ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); cm.unregisterNetworkCallback(networkCallback); setUnseen(null); if (state != null && state.isRunning()) { Log.e("Destroy while monitor running ignoring=" + Helper.isIgnoringOptimizations(this)); state.stop(); } try { stopForeground(true); } catch (Throwable ex) { Log.e(ex); /* OnePlus A6013 - Android 9 java.lang.RuntimeException: Unable to stop service eu.faircode.email.ServiceSynchronize@3995fc9: java.lang.NullPointerException: Attempt to invoke virtual method 'long java.lang.Long.longValue()' on a null object reference at android.app.ActivityThread.handleStopService(ActivityThread.java:3908) at android.app.ActivityThread.access$1900(ActivityThread.java:209) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1826) at android.os.Handler.dispatchMessage(Handler.java:106) at android.os.Looper.loop(Looper.java:193) at android.app.ActivityThread.main(ActivityThread.java:6954) at java.lang.reflect.Method.invoke(Method.java:-2) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:537) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858) Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'long java.lang.Long.longValue()' on a null object reference at android.os.Parcel.createException(Parcel.java:1956) at android.os.Parcel.readException(Parcel.java:1918) at android.os.Parcel.readException(Parcel.java:1868) at android.app.IActivityManager$Stub$Proxy.setServiceForeground(IActivityManager.java:5111) at android.app.Service.stopForeground(Service.java:724) at android.app.Service.stopForeground(Service.java:710) */ } NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.cancel(Helper.NOTIFICATION_SYNCHRONIZE); super.onDestroy(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { lastStartId = startId; String action = (intent == null ? null : intent.getAction()); Log.i("Service command intent=" + intent + " action=" + action); Log.logExtras(intent); super.onStartCommand(intent, flags, startId); startForeground(Helper.NOTIFICATION_SYNCHRONIZE, getNotificationService(null).build()); if (action != null) try { switch (action) { case "alarm": onAlarm(); break; case "reload": onReload( intent.getBooleanExtra("clear", false), intent.getStringExtra("reason")); break; case "reset": onReset(); break; case "oneshot_start": onOneshot(true, false); break; case "oneshot_end": onOneshot(false, false); break; case "watchdog": onWatchdog(); break; default: Log.w("Unknown action: " + action); } } catch (Throwable ex) { Log.e(ex); } return START_STICKY; } private NotificationCompat.Builder getNotificationService(TupleAccountStats stats) { if (stats != null) lastStats = stats; // Build pending intent Intent why = new Intent(this, ActivityView.class); why.setAction("why"); PendingIntent piWhy = PendingIntent.getActivity(this, ActivityView.REQUEST_WHY, why, PendingIntent.FLAG_UPDATE_CURRENT); // Build notification NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "service") .setSmallIcon(R.drawable.baseline_compare_arrows_white_24) .setContentTitle(getResources().getQuantityString( R.plurals.title_notification_synchronizing, lastStats.accounts, lastStats.accounts)) .setContentIntent(piWhy) .setAutoCancel(false) .setShowWhen(false) .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_SECRET) .setLocalOnly(true); if (lastStats.operations > 0) builder.setContentText(getResources().getQuantityString( R.plurals.title_notification_operations, lastStats.operations, lastStats.operations)); if (!networkState.isSuitable()) builder.setSubText(getString(R.string.title_notification_waiting)); return builder; } private void setUnseen(Integer unseen) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean badge = prefs.getBoolean("badge", true); Widget.update(this, unseen); try { if (unseen == null || !badge) ShortcutBadger.removeCount(this); else ShortcutBadger.applyCount(this, unseen); } catch (Throwable ex) { Log.e(ex); } } private void onAlarm() { schedule(this); onReload(true, "alarm"); } private void onReload(boolean clear, String reason) { synchronized (this) { try { queue_reload(true, clear, reason); } catch (Throwable ex) { Log.e(ex); } } } private void onReset() { lastLost = 0; onReload(true, "reset"); } private void onOneshot(boolean start, boolean idle) { Log.i("Oneshot start=" + start + " idle=" + idle); Intent alarm = new Intent(this, ServiceSynchronize.class); alarm.setAction("oneshot_end"); PendingIntent piOneshot; AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) piOneshot = PendingIntent.getService(this, PI_ONESHOT, alarm, PendingIntent.FLAG_UPDATE_CURRENT); else piOneshot = PendingIntent.getForegroundService(this, PI_ONESHOT, alarm, PendingIntent.FLAG_UPDATE_CURRENT); am.cancel(piOneshot); oneshot = start; if (start) { // Network events will manage the service long at = System.currentTimeMillis() + (idle ? ONESHOT_DURATION_IDLE : ONESHOT_DURATION_RUN); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) am.set(AlarmManager.RTC_WAKEUP, at, piOneshot); else am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, piOneshot); if (!started) onReload(true, "oneshot start"); } else onReload(true, "oneshot end"); } private void onWatchdog() { EntityLog.log(this, "Service watchdog"); // Network events will manage the service } private void queue_reload(final boolean start, final boolean clear, final String reason) { final boolean doStop = started; final boolean doStart = (start && isEnabled() && networkState.isSuitable()); EntityLog.log(this, "Queue reload" + " doStop=" + doStop + " doStart=" + doStart + " queued=" + queued + " reason=" + reason); started = doStart; try { queued++; queue.submit(new Runnable() { private PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); private PowerManager.WakeLock wl = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":manage"); @Override public void run() { DB db = DB.getInstance(ServiceSynchronize.this); try { wl.acquire(); EntityLog.log(ServiceSynchronize.this, "Reload" + " stop=" + doStop + " start=" + doStart + " queued=" + queued + " " + reason); Map crumb = new HashMap<>(); crumb.put("oneshot", Boolean.toString(oneshot)); crumb.put("started", Boolean.toString(started)); crumb.put("doStop", Boolean.toString(doStop)); crumb.put("doStart", Boolean.toString(doStart)); crumb.put("queued", Integer.toString(queued)); crumb.put("reason", reason == null ? "" : reason); crumb.put("connected", Boolean.toString(networkState.isConnected())); crumb.put("suitable", Boolean.toString(networkState.isSuitable())); crumb.put("unmetered", Boolean.toString(networkState.isUnmetered())); crumb.put("roaming", Boolean.toString(networkState.isRoaming())); crumb.put("lastLost", new Date(lastLost).toString()); Log.breadcrumb("reload", crumb); if (doStop) stop(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); for (EntityAccount account : db.account().getAccountsTbd()) nm.deleteNotificationChannel(EntityAccount.getNotificationChannelId(account.id)); } int accounts = db.account().deleteAccountsTbd(); int identities = db.identity().deleteIdentitiesTbd(); if (accounts > 0 || identities > 0) Log.i("Deleted accounts=" + accounts + " identities=" + identities); if (doStart) { if (clear) db.account().clearAccountConnected(); start(!oneshot || sync); } } catch (Throwable ex) { Log.e(ex); } finally { queued--; EntityLog.log(ServiceSynchronize.this, "Reload done queued=" + queued); if (!doStart && queued == 0 && !isEnabled()) { try { Thread.sleep(STOP_DELAY); } catch (InterruptedException ignored) { } if (queued == 0 && !isEnabled()) stopService(lastStartId); } wl.release(); } } }); } catch (RejectedExecutionException ex) { Log.w(ex); } } private boolean isEnabled() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean enabled = prefs.getBoolean("enabled", true); int pollInterval = prefs.getInt("poll_interval", 0); return ((enabled && pollInterval == 0) || oneshot); } private void start(final boolean sync) { EntityLog.log(this, "Main start"); final Thread main = Thread.currentThread(); if (state != null && state.isRunning()) { Log.e("Monitor running ignoring=" + Helper.isIgnoringOptimizations(this)); state.stop(); } state = new Core.State(networkState); state.runnable(new Runnable() { private PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); private PowerManager.WakeLock wl = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":main"); @Override public void run() { try { wl.acquire(); final DB db = DB.getInstance(ServiceSynchronize.this); long ago = new Date().getTime() - lastLost; if (ago < RECONNECT_BACKOFF) try { long backoff = RECONNECT_BACKOFF - ago; EntityLog.log(ServiceSynchronize.this, "Main backoff=" + (backoff / 1000)); if (state.acquire(backoff)) return; } catch (InterruptedException ex) { Log.w("main backoff " + ex.toString()); } // Start monitoring accounts List accounts = db.account().getSynchronizingAccounts(); for (final EntityAccount account : accounts) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (account.notify) account.createNotificationChannel(ServiceSynchronize.this); else account.deleteNotificationChannel(ServiceSynchronize.this); } Log.i(account.host + "/" + account.user + " run"); final Core.State astate = new Core.State(state); astate.runnable(new Runnable() { @Override public void run() { try { monitorAccount(account, astate, sync); } catch (Throwable ex) { Log.e(account.name, ex); } } }, "sync.account." + account.id); astate.start(); state.childs.add(astate); } EntityLog.log(ServiceSynchronize.this, "Main started"); try { wl.release(); while (!state.acquire(CHECK_ALIVE_INTERVAL)) { if (!main.isAlive()) { Log.e("Main thread died"); state.stop(); } } } catch (InterruptedException ex) { Log.w("main wait " + ex.toString()); } finally { wl.acquire(); } // Stop monitoring accounts for (Core.State astate : state.childs) astate.stop(); for (Core.State astate : state.childs) astate.join(); state.childs.clear(); EntityLog.log(ServiceSynchronize.this, "Main exited"); } catch (Throwable ex) { // Fail-safe Log.e(ex); } finally { wl.release(); EntityLog.log(ServiceSynchronize.this, "Start wake lock=" + wl.isHeld()); } } }, "sync.main"); state.start(); } private void stop() { EntityLog.log(this, "Main stop"); if (state != null) { state.stop(); state.join(); } EntityLog.log(this, "Main stopped"); state = null; } private void stopService(int startId) { EntityLog.log(this, "Service stop"); DB db = DB.getInstance(this); List ops = db.operation().getOperations(EntityOperation.SYNC); for (EntityOperation op : ops) db.folder().setFolderSyncState(op.folder, null); stopSelf(startId); } private void monitorAccount(final EntityAccount account, final Core.State state, final boolean sync) throws NoSuchProviderException { final PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); final PowerManager.WakeLock wlAccount = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":account." + account.id); final PowerManager.WakeLock wlFolder = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":account." + account.id + ".folder"); final PowerManager.WakeLock wlMessage = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":account." + account.id + ".message"); try { wlAccount.acquire(); final DB db = DB.getInstance(this); int backoff = CONNECT_BACKOFF_START; while (state.isRunning()) { state.reset(); Log.i(account.name + " run"); Handler handler = new Handler(getMainLooper()); final List cowners = new ArrayList<>(); // Debug SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean debug = (prefs.getBoolean("debug", false) || BuildConfig.DEBUG); final MailService iservice = new MailService( this, account.getProtocol(), account.realm, account.insecure, false, debug); iservice.setPartialFetch(account.partial_fetch); iservice.setIgnoreBodyStructureSize(account.ignore_size); if (account.pop) iservice.setLeaveOnServer(account.browse); final Map mapFolders = new HashMap<>(); List idlers = new ArrayList<>(); try { // Initiate connection EntityLog.log(this, account.name + " connecting"); db.folder().setFolderStates(account.id, null); db.account().setAccountState(account.id, "connecting"); try { iservice.connect(account); } catch (Throwable ex) { if (ex instanceof AuthenticationFailedException) { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify("receive:" + account.id, 1, Core.getNotificationError(this, "error", account.name, ex) .build()); throw ex; } // Report account connection error if (account.last_connected != null && !ConnectionHelper.airplaneMode(this)) { EntityLog.log(this, account.name + " last connected: " + new Date(account.last_connected)); long now = new Date().getTime(); long delayed = now - account.last_connected - account.poll_interval * 60 * 1000L; if (delayed > ACCOUNT_ERROR_AFTER * 60 * 1000L && backoff > BACKOFF_ERROR_AFTER) { Log.i("Reporting sync error after=" + delayed); Throwable warning = new Throwable( getString(R.string.title_no_sync, Helper.getDateTimeInstance(this, DateFormat.SHORT, DateFormat.SHORT) .format(account.last_connected)), ex); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify("receive:" + account.id, 1, Core.getNotificationError(this, "warning", account.name, warning) .build()); } } throw ex; } final boolean capIdle = iservice.hasCapability("IDLE"); Log.i(account.name + " idle=" + capIdle); db.account().setAccountState(account.id, "connected"); db.account().setAccountError(account.id, null); db.account().setAccountWarning(account.id, null); EntityLog.log(this, account.name + " connected"); // Listen for store events iservice.getStore().addStoreListener(new StoreListener() { @Override public void notification(StoreEvent e) { if (e.getMessageType() == StoreEvent.NOTICE) EntityLog.log(ServiceSynchronize.this, account.name + " notice: " + e.getMessage()); else try { wlFolder.acquire(); String message = e.getMessage(); Log.w(account.name + " alert: " + message); EntityLog.log( ServiceSynchronize.this, account.name + " " + Helper.formatThrowable(new Core.AlertException(message), false)); db.account().setAccountError(account.id, message); if (message != null && !message.startsWith("Too many simultaneous connections")) { NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify("alert:" + account.id, 1, Core.getNotificationError( ServiceSynchronize.this, "warning", account.name, new Core.AlertException(message)) .build()); } state.error(null); } finally { wlFolder.release(); } } }); // Listen for folder events iservice.getStore().addFolderListener(new FolderAdapter() { @Override public void folderCreated(FolderEvent e) { try { wlFolder.acquire(); String name = e.getFolder().getFullName(); Log.i("Folder created=" + name); if (db.folder().getFolderByName(account.id, name) == null) reload(ServiceSynchronize.this, "folder created"); } finally { wlFolder.release(); } } @Override public void folderRenamed(FolderEvent e) { try { wlFolder.acquire(); String old = e.getFolder().getFullName(); String name = e.getNewFolder().getFullName(); Log.i("Folder renamed from=" + old + " to=" + name); int count = db.folder().renameFolder(account.id, old, name); Log.i("Renamed to " + name + " count=" + count); if (count == 0) reload(ServiceSynchronize.this, "folder renamed"); } finally { wlFolder.release(); } } @Override public void folderDeleted(FolderEvent e) { try { wlFolder.acquire(); String name = e.getFolder().getFullName(); Log.i("Folder deleted=" + name); if (db.folder().getFolderByName(account.id, name) != null) reload(ServiceSynchronize.this, "folder deleted"); } finally { wlFolder.release(); } } }); // Update folder list if (!account.pop) Core.onSynchronizeFolders(this, account, iservice.getStore(), state); // Open synchronizing folders final ExecutorService executor = Helper.getBackgroundExecutor(1, "account_" + account.id); List folders = db.folder().getFolders(account.id, false, true); Collections.sort(folders, new Comparator() { @Override public int compare(EntityFolder f1, EntityFolder f2) { int s1 = EntityFolder.FOLDER_SORT_ORDER.indexOf(f1.type); int s2 = EntityFolder.FOLDER_SORT_ORDER.indexOf(f2.type); int s = Integer.compare(s1, s2); if (s != 0) return s; return f1.name.compareTo(f2.name); } }); for (final EntityFolder folder : folders) { if (folder.synchronize && !folder.poll && capIdle && sync) { Log.i(account.name + " sync folder " + folder.name); db.folder().setFolderState(folder.id, "connecting"); final IMAPFolder ifolder = (IMAPFolder) iservice.getStore().getFolder(folder.name); try { if (BuildConfig.DEBUG && "Postausgang".equals(folder.name)) throw new ReadOnlyFolderException(ifolder); ifolder.open(Folder.READ_WRITE); db.folder().setFolderReadOnly(folder.id, ifolder.getUIDNotSticky()); } catch (ReadOnlyFolderException ex) { Log.w(folder.name + " read only"); try { ifolder.open(Folder.READ_ONLY); db.folder().setFolderReadOnly(folder.id, true); } catch (MessagingException ex1) { Log.w(folder.name, ex1); db.folder().setFolderState(folder.id, null); db.folder().setFolderError(folder.id, Helper.formatThrowable(ex1)); continue; } } catch (FolderNotFoundException ex) { Log.w(folder.name, ex); db.folder().deleteFolder(folder.id); continue; } catch (MessagingException ex) { Log.e(folder.name, ex); db.folder().setFolderState(folder.id, null); if (ex.getCause() instanceof BadCommandException) throw ex; db.folder().setFolderError(folder.id, Helper.formatThrowable(ex)); continue; } catch (Throwable ex) { db.folder().setFolderError(folder.id, Helper.formatThrowable(ex)); throw ex; } mapFolders.put(folder, ifolder); db.folder().setFolderState(folder.id, "connected"); db.folder().setFolderError(folder.id, null); int count = ifolder.getMessageCount(); db.folder().setFolderTotal(folder.id, count < 0 ? null : count); Log.i(account.name + " folder " + folder.name + " flags=" + ifolder.getPermanentFlags()); // Listen for new and deleted messages ifolder.addMessageCountListener(new MessageCountAdapter() { @Override public void messagesAdded(MessageCountEvent e) { try { wlMessage.acquire(); Log.i(folder.name + " messages added"); for (Message imessage : e.getMessages()) { long uid = ifolder.getUID(imessage); EntityOperation.queue(ServiceSynchronize.this, folder, EntityOperation.FETCH, uid); } } catch (Throwable ex) { Log.e(folder.name, ex); EntityLog.log( ServiceSynchronize.this, folder.name + " " + Helper.formatThrowable(ex, false)); state.error(ex); } finally { wlMessage.release(); } } @Override public void messagesRemoved(MessageCountEvent e) { try { wlMessage.acquire(); Log.i(folder.name + " messages removed"); for (Message imessage : e.getMessages()) { long uid = ifolder.getUID(imessage); EntityOperation.queue(ServiceSynchronize.this, folder, EntityOperation.FETCH, uid); } } catch (Throwable ex) { Log.e(folder.name, ex); EntityLog.log( ServiceSynchronize.this, folder.name + " " + Helper.formatThrowable(ex, false)); state.error(ex); } finally { wlMessage.release(); } } }); // Flags (like "seen") at the remote could be changed while synchronizing // Listen for changed messages ifolder.addMessageChangedListener(new MessageChangedListener() { @Override public void messageChanged(MessageChangedEvent e) { try { wlMessage.acquire(); Log.i(folder.name + " message changed"); long uid = ifolder.getUID(e.getMessage()); EntityOperation.queue(ServiceSynchronize.this, folder, EntityOperation.FETCH, uid); } catch (Throwable ex) { Log.e(folder.name, ex); EntityLog.log( ServiceSynchronize.this, folder.name + " " + Helper.formatThrowable(ex, false)); state.error(ex); } finally { wlMessage.release(); } } }); // Idle folder Thread idler = new Thread(new Runnable() { @Override public void run() { try { Log.i(folder.name + " start idle"); while (ifolder.isOpen() && state.isRunning() && state.isRecoverable()) { Log.i(folder.name + " do idle"); ifolder.idle(false); } } catch (Throwable ex) { Log.e(folder.name, ex); EntityLog.log( ServiceSynchronize.this, folder.name + " " + Helper.formatThrowable(ex, false)); state.error(new FolderClosedException(ifolder, "IDLE")); } finally { Log.i(folder.name + " end idle"); } } }, "idler." + folder.id); idler.setPriority(THREAD_PRIORITY_BACKGROUND); idler.start(); idlers.add(idler); if (sync && folder.selectable) EntityOperation.sync(this, folder.id, false); } else mapFolders.put(folder, null); Log.i(folder.name + " observing"); handler.post(new Runnable() { @Override public void run() { TwoStateOwner cowner = new TwoStateOwner(ServiceSynchronize.this, folder.name); cowners.add(cowner); cowner.start(); db.operation().liveOperations(folder.id).observe(cowner, new Observer>() { private List handling = new ArrayList<>(); private final PowerManager.WakeLock wlFolder = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":folder." + folder.id); @Override public void onChanged(final List operations) { boolean process = false; List ops = new ArrayList<>(); for (EntityOperation op : operations) { if (!handling.contains(op.id)) process = true; ops.add(op.id); } handling = ops; if (handling.size() > 0 && process) { Log.i(folder.name + " operations=" + operations.size() + " init=" + folder.initialize + " poll=" + folder.poll); executor.submit(new Runnable() { @Override public void run() { try { wlFolder.acquire(); Log.i(folder.name + " process"); // Get folder Folder ifolder = mapFolders.get(folder); // null when polling boolean canOpen = (!account.pop || EntityFolder.INBOX.equals(folder.type)); final boolean shouldClose = (ifolder == null && canOpen); try { Log.i(folder.name + " run " + (shouldClose ? "offline" : "online")); if (shouldClose) { // Prevent unnecessary folder connections if (db.operation().getOperationCount(folder.id, null) == 0) return; db.folder().setFolderState(folder.id, "connecting"); ifolder = iservice.getStore().getFolder(folder.name); ifolder.open(Folder.READ_WRITE); db.folder().setFolderState(folder.id, "connected"); db.folder().setFolderError(folder.id, null); } Core.processOperations(ServiceSynchronize.this, account, folder, iservice.getStore(), ifolder, state); } catch (FolderNotFoundException ex) { Log.w(folder.name, ex); db.folder().deleteFolder(folder.id); } catch (Throwable ex) { Log.e(folder.name, ex); EntityLog.log( ServiceSynchronize.this, folder.name + " " + Helper.formatThrowable(ex, false)); db.folder().setFolderError(folder.id, Helper.formatThrowable(ex)); state.error(ex); } finally { if (shouldClose) { if (ifolder != null && ifolder.isOpen()) { db.folder().setFolderState(folder.id, "closing"); try { ifolder.close(false); } catch (MessagingException ex) { Log.w(folder.name, ex); } } if (folder.synchronize && (folder.poll || !capIdle)) db.folder().setFolderState(folder.id, "waiting"); else db.folder().setFolderState(folder.id, null); } } } finally { wlFolder.release(); } } }); } } }); } }); } // Keep alive alarm receiver BroadcastReceiver alarm = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { // Receiver runs on main thread // Receiver has a wake lock for ~10 seconds EntityLog.log(context, account.name + " keep alive wake lock=" + wlAccount.isHeld()); state.release(); } }; String id = BuildConfig.APPLICATION_ID + ".POLL." + account.id + "." + new Random().nextInt(); PendingIntent pi = PendingIntent.getBroadcast(this, 0, new Intent(id), 0); registerReceiver(alarm, new IntentFilter(id)); // Keep alive AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); try { while (state.isRunning()) { if (!state.isRecoverable()) throw new StoreClosedException(iservice.getStore(), "Unrecoverable"); // Sends store NOOP if (!iservice.getStore().isConnected()) throw new StoreClosedException(iservice.getStore(), "NOOP"); if (sync) for (EntityFolder folder : mapFolders.keySet()) if (folder.synchronize) if (!folder.poll && capIdle) { // Sends folder NOOP if (!mapFolders.get(folder).isOpen()) throw new StoreClosedException(iservice.getStore(), folder.name); } else EntityOperation.sync(this, folder.id, false); // Successfully connected: reset back off time backoff = CONNECT_BACKOFF_START; // Record successful connection account.last_connected = new Date().getTime(); EntityLog.log(this, account.name + " set last_connected=" + new Date(account.last_connected)); db.account().setAccountConnected(account.id, account.last_connected); db.account().setAccountWarning(account.id, capIdle ? null : getString(R.string.title_no_idle)); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.cancel("receive:" + account.id, 1); // Schedule keep alive alarm EntityLog.log(this, account.name + " wait=" + account.poll_interval); AlarmManagerCompat.setAndAllowWhileIdle(am, AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + account.poll_interval * 60 * 1000L, pi); try { wlAccount.release(); state.acquire(); } catch (InterruptedException ex) { EntityLog.log(this, account.name + " waited state=" + state); } finally { wlAccount.acquire(); } } } finally { // Cleanup am.cancel(pi); try { unregisterReceiver(alarm); } catch (IllegalArgumentException ex) { Log.e(ex); } } Log.i(account.name + " done state=" + state); } catch (StoreClosedException ex) { Log.w(account.name, ex); } catch (Throwable ex) { Log.e(account.name, ex); EntityLog.log( ServiceSynchronize.this, account.name + " " + Helper.formatThrowable(ex, false)); db.account().setAccountError(account.id, Helper.formatThrowable(ex)); } finally { // Stop watching for operations handler.post(new Runnable() { @Override public void run() { for (TwoStateOwner owner : cowners) owner.destroy(); } }); // Update state EntityLog.log(this, account.name + " closing"); db.account().setAccountState(account.id, "closing"); for (EntityFolder folder : mapFolders.keySet()) if (folder.synchronize && !folder.poll && mapFolders.get(folder) != null) db.folder().setFolderState(folder.id, "closing"); // Close store try { EntityLog.log(this, account.name + " store closing"); iservice.close(); EntityLog.log(this, account.name + " store closed"); } catch (Throwable ex) { Log.w(account.name, ex); } finally { EntityLog.log(this, account.name + " closed"); db.account().setAccountState(account.id, null); } // Stop idlers for (Thread idler : idlers) state.join(idler); idlers.clear(); // Update state for (EntityFolder folder : mapFolders.keySet()) if (folder.synchronize && !folder.poll && mapFolders.get(folder) != null) db.folder().setFolderState(folder.id, null); } if (state.isRunning()) try { if (backoff <= CONNECT_BACKOFF_MAX) { // Short back-off period, keep device awake EntityLog.log(this, account.name + " backoff=" + backoff); state.acquire(backoff * 1000L); } else { // Long back-off period, let device sleep EntityLog.log(this, account.name + " backoff alarm=" + CONNECT_BACKOFF_AlARM); BroadcastReceiver alarm = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { state.release(); } }; String id = BuildConfig.APPLICATION_ID + ".BACKOFF." + account.id + "." + new Random().nextInt(); PendingIntent pi = PendingIntent.getBroadcast(this, 0, new Intent(id), 0); registerReceiver(alarm, new IntentFilter(id)); AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); try { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) am.set( AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + CONNECT_BACKOFF_AlARM * 60 * 1000L, pi); else am.setAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + CONNECT_BACKOFF_AlARM * 60 * 1000L, pi); try { wlAccount.release(); state.acquire(2 * CONNECT_BACKOFF_AlARM * 60 * 1000L); } finally { wlAccount.acquire(); } } finally { // Cleanup am.cancel(pi); try { unregisterReceiver(alarm); } catch (IllegalArgumentException ex) { Log.e(ex); } } } if (backoff <= CONNECT_BACKOFF_MAX) backoff *= 2; } catch (InterruptedException ex) { Log.w(account.name + " backoff " + ex.toString()); } } } finally { EntityLog.log(this, account.name + " stopped"); wlAccount.release(); } } private ConnectivityManager.NetworkCallback networkCallback = new ConnectivityManager.NetworkCallback() { private Boolean lastSuitable = null; @Override public void onAvailable(Network network) { updateState(); synchronized (ServiceSynchronize.this) { try { ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); EntityLog.log(ServiceSynchronize.this, "Available " + network + " capabilities " + cm.getNetworkCapabilities(network) + " connected=" + networkState.isConnected() + " suitable=" + networkState.isSuitable() + " unmetered=" + networkState.isUnmetered() + " roaming=" + networkState.isRoaming() + " started=" + started); if (networkState.isSuitable()) if (started) { EntityLog.log(ServiceSynchronize.this, "Checking account states"); Thread check = new Thread(new Runnable() { @Override public void run() { try { DB db = DB.getInstance(ServiceSynchronize.this); boolean disconnected = false; List accounts = db.account().getSynchronizingAccounts(); for (EntityAccount account : accounts) if (!"connected".equals(account.state)) { disconnected = true; break; } if (disconnected) new Handler(getMainLooper()).post(new Runnable() { @Override public void run() { try { synchronized (ServiceSynchronize.this) { queue_reload(true, false, "Some accounts disconnected"); } } catch (Throwable ex) { Log.e(ex); } } }); } catch (Throwable ex) { Log.e(ex); } } }, "synchronize:connectivity"); check.setPriority(THREAD_PRIORITY_BACKGROUND); check.start(); } else queue_reload(true, false, "connect " + network); } catch (Throwable ex) { Log.e(ex); } } } @Override public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) { updateState(); synchronized (ServiceSynchronize.this) { try { if (!started) { EntityLog.log(ServiceSynchronize.this, "Network " + network + " capabilities " + capabilities); if (networkState.isSuitable()) queue_reload(true, false, "capabilities " + network); } } catch (Throwable ex) { Log.e(ex); } } } @Override public void onLost(Network network) { updateState(); synchronized (ServiceSynchronize.this) { try { ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); EntityLog.log(ServiceSynchronize.this, "Lost " + network + " active=" + cm.getActiveNetworkInfo() + " connected=" + networkState.isConnected() + " suitable=" + networkState.isSuitable() + " unmetered=" + networkState.isUnmetered() + " roaming=" + networkState.isRoaming() + " started=" + started); if (started && !networkState.isSuitable()) { lastLost = new Date().getTime(); queue_reload(false, false, "disconnect " + network); } } catch (Throwable ex) { Log.e(ex); } } } private void updateState() { networkState.update(ConnectionHelper.getNetworkState(ServiceSynchronize.this)); if (lastSuitable == null || lastSuitable != networkState.isSuitable()) { lastSuitable = networkState.isSuitable(); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); nm.notify(Helper.NOTIFICATION_SYNCHRONIZE, getNotificationService(lastStats).build()); } } }; private BroadcastReceiver connectionChangedReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { EntityLog.log(ServiceSynchronize.this, "Received intent=" + intent + " " + TextUtils.join(" ", Log.getExtras(intent.getExtras()))); if (Intent.ACTION_AIRPLANE_MODE_CHANGED.equals(intent.getAction())) { boolean on = intent.getBooleanExtra("state", false); if (!on) lastLost = 0; } networkCallback.onCapabilitiesChanged(null, null); } }; private BroadcastReceiver onScreenOff = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { Log.i("Received " + intent); Log.logExtras(intent); Helper.clearAuthentication(ServiceSynchronize.this); } }; static void boot(final Context context) { Thread thread = new Thread(new Runnable() { @Override public void run() { try { DB db = DB.getInstance(context); // Restore notifications db.message().clearNotifyingMessages(); // Restore snooze timers for (EntityMessage message : db.message().getSnoozed()) EntityMessage.snooze(context, message.id, message.ui_snoozed); // Restore schedule schedule(context); // Conditionally init service int accounts = db.account().getSynchronizingAccounts().size(); if (accounts > 0) watchdog(context); else { for (EntityAccount account : db.account().getAccounts()) db.account().setAccountState(account.id, null); for (EntityFolder folder : db.folder().getFolders()) { db.folder().setFolderState(folder.id, null); db.folder().setFolderSyncState(folder.id, null); } } } catch (Throwable ex) { Log.e(ex); } } }, "synchronize:boot"); thread.setPriority(THREAD_PRIORITY_BACKGROUND); thread.start(); } private static void schedule(Context context) { Intent alarm = new Intent(context, ServiceSynchronize.class); alarm.setAction("alarm"); PendingIntent piAlarm; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) piAlarm = PendingIntent.getService(context, PI_ALARM, alarm, PendingIntent.FLAG_UPDATE_CURRENT); else piAlarm = PendingIntent.getForegroundService(context, PI_ALARM, alarm, PendingIntent.FLAG_UPDATE_CURRENT); AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); am.cancel(piAlarm); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (!prefs.getBoolean("schedule", false) || !ActivityBilling.isPro(context)) return; int minuteStart = prefs.getInt("schedule_start", 0); int minuteEnd = prefs.getInt("schedule_end", 0); if (minuteEnd <= minuteStart) minuteEnd += 24 * 60; Calendar calStart = Calendar.getInstance(); calStart.set(Calendar.HOUR_OF_DAY, minuteStart / 60); calStart.set(Calendar.MINUTE, minuteStart % 60); calStart.set(Calendar.SECOND, 0); calStart.set(Calendar.MILLISECOND, 0); Calendar calEnd = Calendar.getInstance(); calEnd.set(Calendar.HOUR_OF_DAY, minuteEnd / 60); calEnd.set(Calendar.MINUTE, minuteEnd % 60); calEnd.set(Calendar.SECOND, 0); calEnd.set(Calendar.MILLISECOND, 0); long now = new Date().getTime(); if (now > calEnd.getTimeInMillis()) { calStart.set(Calendar.DAY_OF_MONTH, calStart.get(Calendar.DAY_OF_MONTH) + 1); calEnd.set(Calendar.DAY_OF_MONTH, calEnd.get(Calendar.DAY_OF_MONTH) + 1); } long start = calStart.getTimeInMillis(); long end = calEnd.getTimeInMillis(); long next = (now < start ? start : end); Log.i("Schedule now=" + new Date(now)); Log.i("Schedule start=" + new Date(start)); Log.i("Schedule end=" + new Date(end)); Log.i("Schedule next=" + new Date(next)); boolean enabled = (now >= start && now < end); prefs.edit().putBoolean("enabled", enabled).apply(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, piAlarm); else am.set(AlarmManager.RTC_WAKEUP, next, piAlarm); WorkerPoll.init(context); } static void reschedule(Context context) { ContextCompat.startForegroundService(context, new Intent(context, ServiceSynchronize.class) .setAction("alarm")); } static void reload(Context context, String reason) { reload(context, false, reason); } static void reload(Context context, boolean clear, String reason) { ContextCompat.startForegroundService(context, new Intent(context, ServiceSynchronize.class) .setAction("reload") .putExtra("clear", clear) .putExtra("reason", reason)); } static void reset(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean enabled = prefs.getBoolean("enabled", true); int pollInterval = prefs.getInt("poll_interval", 0); if (!enabled || pollInterval > 0) { ServiceSynchronize.sync = true; ServiceSynchronize.oneshot = true; } ContextCompat.startForegroundService(context, new Intent(context, ServiceSynchronize.class) .setAction("reset")); } static void process(Context context, boolean sync) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean enabled = prefs.getBoolean("enabled", true); int pollInterval = prefs.getInt("poll_interval", 0); if (!enabled || pollInterval > 0) { ServiceSynchronize.sync = sync; ServiceSynchronize.oneshot = true; ContextCompat.startForegroundService(context, new Intent(context, ServiceSynchronize.class) .setAction("oneshot_start")); } } static void watchdog(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean enabled = prefs.getBoolean("enabled", true); int pollInterval = prefs.getInt("poll_interval", 0); if (enabled && pollInterval == 0) ContextCompat.startForegroundService(context, new Intent(context, ServiceSynchronize.class) .setAction("watchdog")); } }