/* * Copyright (c) 2016. Eli Connelly * * 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.emogoth.android.phone.mimi.autorefresh; import android.app.Activity; import android.app.job.JobInfo; import android.app.job.JobScheduler; import android.content.ComponentName; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.os.PersistableBundle; import androidx.preference.PreferenceManager; import androidx.core.app.NotificationManagerCompat; import android.util.Log; import com.emogoth.android.phone.mimi.BuildConfig; import com.emogoth.android.phone.mimi.R; import com.emogoth.android.phone.mimi.app.MimiApplication; import com.emogoth.android.phone.mimi.db.DatabaseUtils; import com.emogoth.android.phone.mimi.db.HistoryTableConnection; import com.emogoth.android.phone.mimi.db.model.History; import com.emogoth.android.phone.mimi.model.ThreadInfo; import com.emogoth.android.phone.mimi.model.ThreadRegistryModel; import com.emogoth.android.phone.mimi.util.AppRater; import com.emogoth.android.phone.mimi.util.MimiPrefs; import com.emogoth.android.phone.mimi.util.RxUtil; import com.emogoth.android.phone.mimi.util.ThreadRegistry; import com.squareup.tape2.ObjectQueue; import com.squareup.tape2.QueueFile; import java.io.File; import java.io.IOException; import java.util.Collections; import java.util.HashSet; import java.util.Set; import io.reactivex.disposables.Disposable; public class RefreshScheduler { public static final String LOG_TAG = RefreshScheduler.class.getSimpleName(); public static final boolean LOG_DEBUG = BuildConfig.DEBUG; private static final int JOB_ID = 8382; public static final String INTENT_FILTER = "com.emogoth.android.phone.mimi.AutoRefresh"; public static final String REFRESH_QUEUE_FILE_NAME = "refresh.queue"; public static final String REFRESH_QUEUE_PREF_FILE = "refresh_scheduler"; public static final String REMOVED_THREADS_PREF = "removed_threads_pref"; public static final String ADDED_THREADS_PREF = "added_threads_pref"; public static final String POST_KEY = "post"; public static final String DATA_KEY = "data"; public static final String RESULT_KEY = "result"; public static final String THREAD_INFO_KEY = "threadinfo"; public static final String REFRESH_TIME_KEY = "timestamp"; public static final String HACK_BUNDLE_KEY = "hack_bundle"; public static final String BACKGROUNDED_KEY = "backgrounded"; public static final String ERROR_CODE = "error_code"; public static final String THREAD_ID_EXTRA = "thread_id"; public static final String BOARD_NAME_EXTRA = "board_name"; public static final String BOARD_TITLE_EXTRA = "board_title"; public static final String WATCHED_EXTRA = "watched"; public static final String LAST_REFRESH_TIME_EXTRA = "last_refresh_time"; public static final Long MIN_TIME_BETWEEN_REFRESH = 0L; public static final Long MAX_TIME_BETWEEN_REGISTER = 5000L; public static final int RESULT_SUCCESS = 0; public static final int RESULT_ERROR = 1; public static final int RESULT_SCHEDULED = 2; public static final int REFRESH_TIMEOUT = 10000; private Sequencer sequencer; private ObjectQueue<ThreadInfo> refreshQueue; private int refreshInterval = 0; private int backgroundRefreshInterval = 0; private Context context; private JobScheduler refreshScheduler; private AutoRefreshListener callback; private boolean refreshActive = false; private boolean waitingForService = false; private boolean backgrounded = true; private final Object lock = new Object(); private Handler registrationHandler = new Handler(); private Runnable stopScheduler = () -> shutdown(); private Runnable backgroundScheduler = () -> { backgrounded = true; refreshInterval = backgroundRefreshInterval; // scheduleNextRun(); }; private static RefreshScheduler instance; private Disposable fetchHistorySubscription; private RefreshScheduler() { if (LOG_DEBUG) { Log.d(LOG_TAG, "Creating refresh scheduler"); } // hack to hopefully fix oom crashes SharedPreferences prefs = AppRater.getAppRaterPrefs(MimiApplication.getInstance()); int lastVersion = prefs.getInt("currentversion", 0); if (lastVersion < 138) { try { Log.d(LOG_TAG, "Removing refresh queue file"); File queueFile = new File(MimiApplication.getInstance().getFilesDir(), REFRESH_QUEUE_FILE_NAME); if (queueFile.exists()) { Log.d(LOG_TAG, "Refresh queue file exists; deleting"); queueFile.delete(); } else { Log.d(LOG_TAG, "Refresh queue file does not exist"); } } catch (Exception e) { Log.e(LOG_TAG, "Error while purging old queue file", e); } } else { Log.d(LOG_TAG, "Not removing refresh queue file"); } try { File queueFileLocation = new File(MimiApplication.getInstance().getFilesDir(), REFRESH_QUEUE_FILE_NAME); QueueFile queueFile = new QueueFile.Builder(queueFileLocation).build(); refreshQueue = ObjectQueue.create(queueFile, new RefreshQueueConverter()); sequencer = new Sequencer(); init(MimiApplication.getInstance()); } catch (IOException e) { Log.w(LOG_TAG, "Could not create an instance of RefreshScheduler; retrying", e); try { File queueFileLocation = new File(MimiApplication.getInstance().getFilesDir(), REFRESH_QUEUE_FILE_NAME); if (queueFileLocation.exists()) { queueFileLocation.delete(); } QueueFile queueFile = new QueueFile.Builder(queueFileLocation).build(); refreshQueue = ObjectQueue.create(queueFile, new RefreshQueueConverter()); sequencer = new Sequencer(); sequencer.setOnSequencerStartedCallback(this::loadBookmarkFiles); } catch (IOException error) { Log.e(LOG_TAG, "Could not create an instance of RefreshScheduler; exiting", e); } } } public static RefreshScheduler getInstance() { if (instance == null) { instance = new RefreshScheduler(); } return instance; } public void init(final Context context) { if (LOG_DEBUG) { Log.d(LOG_TAG, "Initializing refresh scheduler"); } this.context = context; cleanupPrefs(); refreshInterval = MimiPrefs.refreshInterval(context); sequencer.start(); refreshScheduler = (JobScheduler) this.context.getSystemService(Context.JOB_SCHEDULER_SERVICE); } private void cleanupPrefs() { try { SharedPreferences prefs = getSchedulerPrefs(); Set<String> addedThreads = prefs.getStringSet(ADDED_THREADS_PREF, new HashSet<>()); Set<String> removedThreads = prefs.getStringSet(REMOVED_THREADS_PREF, new HashSet<>()); if (refreshQueue != null && refreshQueue.size() == 0) { boolean addedEmpty = true; boolean removedEmpty = true; if (addedThreads.size() > 0) { addedEmpty = false; } if (removedThreads.size() > 0) { removedEmpty = false; } if (!addedEmpty || !removedEmpty) { SharedPreferences.Editor edits = prefs.edit(); if (!addedEmpty) { edits.remove(ADDED_THREADS_PREF); } if (!removedEmpty) { edits.remove(REMOVED_THREADS_PREF); } edits.apply(); } } } catch (Exception e) { Log.e(LOG_TAG, "Error cleaning up refreshed threads prefs", e); } } private void loadBookmarkFiles() { RxUtil.safeUnsubscribe(fetchHistorySubscription); fetchHistorySubscription = HistoryTableConnection.fetchHistory(true) .compose(DatabaseUtils.applySingleSchedulers()) .onErrorReturn(throwable -> { Log.w(LOG_TAG, "Error fetching history", throwable); return Collections.emptyList(); }) .subscribe(historyList -> { if (historyList.size() == 0) { return; } resetQueuePrefs(); for (History bookmark : historyList) { final ThreadInfo threadInfo = new ThreadInfo(bookmark.threadId, bookmark.boardName, 0, bookmark.watched == 1); addThread(threadInfo.boardName, threadInfo.threadId, threadInfo.watched); } if (!refreshActive) { scheduleNextRun(); } if (LOG_DEBUG) { Log.d(LOG_TAG, "Initializing refresh scheduler: thread size=" + historyList.size() + ", refresh interval=" + refreshInterval + ", backgrounded=" + backgrounded); } }, throwable -> { if (LOG_DEBUG) { Log.w(LOG_TAG, "Error while processing history", throwable); } }); } public void addThread(String boardName, long threadId, boolean watched) { if (refreshQueue == null) { File queueFileLocation = new File(MimiApplication.getInstance().getFilesDir(), REFRESH_QUEUE_FILE_NAME); try { QueueFile queueFile = new QueueFile.Builder(queueFileLocation).build(); refreshQueue = ObjectQueue.create(queueFile, new RefreshQueueConverter()); } catch (IOException e) { Log.w(LOG_TAG, "Could not create refresh queue file"); refreshQueue = null; return; } } if (sequencer.started) { sequencer.addThread(boardName, threadId, watched); } else { ThreadInfo threadInfo = new ThreadInfo(threadId, boardName, null, watched); addThreadSynchronized(threadInfo); } } private void addThreadSynchronized(final ThreadInfo threadInfo) { if (threadInfo == null) { return; } if (LOG_DEBUG) { Log.w(LOG_TAG, "Adding thread: id=/" + threadInfo.boardName + "/" + threadInfo.threadId); } SharedPreferences prefs = getSchedulerPrefs(); Set<String> addedThreads = prefs.getStringSet(ADDED_THREADS_PREF, new HashSet<>()); String id = String.valueOf(threadInfo.threadId); if (!addedThreads.contains(id)) { addedThreads.add(String.valueOf(threadInfo.threadId)); SharedPreferences.Editor editor = prefs.edit(); editor.remove(ADDED_THREADS_PREF).commit(); editor.putStringSet(ADDED_THREADS_PREF, addedThreads).commit(); try { refreshQueue.add(threadInfo); if (LOG_DEBUG) { Log.d(LOG_TAG, "Adding thread to queue file: id=" + threadInfo.threadId); for (String addedThread : addedThreads) { Log.d(LOG_TAG, "Added thread: id=" + addedThread); } } if (!refreshActive) { if (LOG_DEBUG) { Log.d(LOG_TAG, "[addThreadSynchronized] Setting refresh active to true"); } refreshActive = true; scheduleNextRun(); } } catch (Exception e) { Log.e(LOG_TAG, "Failed to add thread to refresh scheduler", e); } } } private SharedPreferences getSchedulerPrefs() { return MimiApplication.getInstance().getSharedPreferences(REFRESH_QUEUE_PREF_FILE, Context.MODE_PRIVATE); } public void removeThread(final String boardName, final long threadId) { if (LOG_DEBUG) { Log.e(LOG_TAG, "Removing thread: id=/" + boardName + "/" + threadId); } if (sequencer.started) { sequencer.removeThread(boardName, threadId); } else { removeThreadSynchronized(boardName, threadId); } } private void resetQueuePrefs() { SharedPreferences prefs = getSchedulerPrefs(); prefs.edit().remove(ADDED_THREADS_PREF).remove(REMOVED_THREADS_PREF).apply(); } protected void removeThreadSynchronized(final String boardName, final long threadId) { SharedPreferences prefs = getSchedulerPrefs(); Set<String> removedThreads = prefs.getStringSet(REMOVED_THREADS_PREF, new HashSet<String>()); removedThreads.add(String.valueOf(threadId)); SharedPreferences.Editor editor = prefs.edit(); editor.remove(REMOVED_THREADS_PREF).commit(); editor.putStringSet(REMOVED_THREADS_PREF, removedThreads).commit(); if (LOG_DEBUG) { for (String removedThread : removedThreads) { Log.d(LOG_TAG, "Removed thread: id=" + removedThread); } } } public void setWaitingForService(boolean waiting) { waitingForService = waiting; } public void onRefreshStart(Bundle extras) { if (callback != null) { callback.onRefreshStart(extras); } } public void setInterval(final int timeout) { sequencer.updateInterval(timeout); } public void setIntervalSynchronized(final int interval) { refreshInterval = interval; } public void setAutoRefreshListener(final AutoRefreshListener listener) { callback = listener; } public void stop() { try { refreshScheduler.cancel(JOB_ID); refreshQueue.close(); refreshQueue = null; if (LOG_DEBUG) { Log.d(LOG_TAG, "[stop] Setting refresh active to false"); } refreshActive = false; } catch (Exception e) { Log.e(LOG_TAG, "Error stopping refresh scheduler", e); } } public void scheduleNextRun() { if (LOG_DEBUG) { Log.d(LOG_TAG, "[scheduleNextRun] Setting refresh active to true"); } refreshActive = true; if (sequencer.started) { sequencer.scheduleNextRun(); } else { scheduleNextRunSynchronized(); } } private void scheduleNextRunSynchronized() { if (refreshQueue == null) { if (LOG_DEBUG) { Log.d(LOG_TAG, "refreshQueue is null; not scheduling next"); } return; } final ThreadInfo nextPostToRefresh; try { nextPostToRefresh = refreshQueue.peek(); } catch (Exception e) { Log.w(LOG_TAG, "Error peeking into queue", e); refreshActive = false; purgeQueueAndReload(true); return; } if (nextPostToRefresh == null) { Log.w(LOG_TAG, "Next post to refresh is null; exiting"); try { if (refreshQueue == null || refreshQueue.size() == 0) { stop(); } else { refreshQueue.remove(); scheduleNextRun(); } } catch (Exception e) { Log.d(LOG_TAG, "Exception while checking refresh queue size", e); stop(); } return; } else { ThreadRegistryModel t = ThreadRegistry.getInstance().getThread(nextPostToRefresh.threadId); if (t != null) { nextPostToRefresh.watched = t.isBookmarked(); } } SharedPreferences prefs = getSchedulerPrefs(); if (refreshQueue.size() > 0) { try { refreshQueue.remove(); } catch (IOException e) { Log.e(LOG_TAG, "Caught exception while removing queued item; exiting", e); return; } Set<String> removedThreads = prefs.getStringSet(REMOVED_THREADS_PREF, new HashSet<>()); if (removedThreads.contains(String.valueOf(nextPostToRefresh.threadId)) || (backgrounded && !nextPostToRefresh.watched)) { try { Set<String> addedThreads = prefs.getStringSet(ADDED_THREADS_PREF, new HashSet<>()); addedThreads.remove(String.valueOf(nextPostToRefresh.threadId)); removedThreads.remove(String.valueOf(nextPostToRefresh.threadId)); SharedPreferences.Editor editor = prefs.edit(); editor.remove(REMOVED_THREADS_PREF).remove(ADDED_THREADS_PREF).commit(); editor.putStringSet(REMOVED_THREADS_PREF, removedThreads).putStringSet(ADDED_THREADS_PREF, addedThreads).commit(); } catch (Exception e) { Log.w(LOG_TAG, "Could not remove thread from removedThreads list", e); } scheduleNextRun(); return; } long lastRefreshTime = nextPostToRefresh.refreshTimestamp; long nextRefreshTime = lastRefreshTime + (refreshInterval * 1000); final Long delta = nextRefreshTime - System.currentTimeMillis(); if (delta < MIN_TIME_BETWEEN_REFRESH) { if (delta > 0) { nextRefreshTime = nextRefreshTime + MIN_TIME_BETWEEN_REFRESH; } else { nextRefreshTime = System.currentTimeMillis() + MIN_TIME_BETWEEN_REFRESH; } } nextPostToRefresh.setTimestamp(nextRefreshTime); if (LOG_DEBUG) { Log.d(LOG_TAG, "Scheduling next refresh cycle: active=" + refreshActive + ", thread=/" + nextPostToRefresh.boardName + "/" + nextPostToRefresh.threadId + ", time=" + (nextRefreshTime - System.currentTimeMillis()) + " ms" + ", backgrounded=" + backgrounded); } scheduleJob(nextPostToRefresh, nextRefreshTime); try { refreshQueue.add(nextPostToRefresh); } catch (IOException e) { Log.e(LOG_TAG, "Caught exception while adding nenxt post to refresh; exiting", e); stop(); } if (LOG_DEBUG) { Log.d(LOG_TAG, "Scheduled next refresh cycle: active=" + refreshActive + ", thread=/" + nextPostToRefresh.boardName + "/" + nextPostToRefresh.threadId + ", time=" + (nextRefreshTime - System.currentTimeMillis()) + " ms" + ", backgrounded=" + backgrounded); } } else { if (LOG_DEBUG) { Log.d(LOG_TAG, "Shutting down refresh service: no more threads to refresh"); } stop(); } } private void scheduleJob(ThreadInfo threadInfo, long nextRunTime) { long delay = nextRunTime - System.currentTimeMillis(); PersistableBundle bundle = new PersistableBundle(); bundle.putLong(THREAD_ID_EXTRA, threadInfo.threadId); bundle.putString(BOARD_NAME_EXTRA, threadInfo.boardName); bundle.putString(BOARD_TITLE_EXTRA, threadInfo.boardTitle); bundle.putInt(WATCHED_EXTRA, threadInfo.watched ? 1 : 0); bundle.putLong(LAST_REFRESH_TIME_EXTRA, threadInfo.refreshTimestamp); bundle.putInt(RESULT_KEY, RESULT_SCHEDULED); bundle.putInt(BACKGROUNDED_KEY, backgrounded ? 1 : 0); ComponentName serviceComponent = new ComponentName(context, RefreshJobService.class); JobInfo jobInfo = new JobInfo.Builder(JOB_ID, serviceComponent) .setMinimumLatency(delay) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) .setRequiresCharging(false) .setExtras(bundle) .build(); refreshScheduler.schedule(jobInfo); } public void register(final Activity activity) { synchronized (lock) { // lock = activity; if (LOG_DEBUG) { Log.d(LOG_TAG, "Registering activity: name=" + activity.getClass().getSimpleName()); } refreshInterval = MimiPrefs.refreshInterval(activity); registrationHandler.removeCallbacks(stopScheduler); registrationHandler.removeCallbacks(backgroundScheduler); if (backgrounded) { backgrounded = false; hideNotification(activity); try { if (refreshInterval > 0) { purgeQueueAndReload(false); } else { Log.d(LOG_TAG, "Not starting refresh; interval is 0)"); } } catch (Exception e) { Log.w(LOG_TAG, "Error creating refresh queue file", e); } } } } private void hideNotification(Context context) { NotificationManagerCompat manager = NotificationManagerCompat.from(context); manager.cancel(RefreshJobService.NOTIFICATION_ID); } private void purgeQueueAndReload(boolean forceNewFile) { try { if (refreshInterval > 0) { if (refreshQueue == null || forceNewFile) { if (refreshQueue != null) { try { refreshQueue.close(); } catch (Exception e) { Log.e(LOG_TAG, "Caught exception while closing refresh queue", e); } } File queueFileLocation = new File(MimiApplication.getInstance().getFilesDir(), REFRESH_QUEUE_FILE_NAME); if (queueFileLocation.exists()) { queueFileLocation.delete(); } QueueFile queueFile = new QueueFile.Builder(queueFileLocation).build(); refreshQueue = ObjectQueue.create(queueFile, new RefreshQueueConverter()); loadBookmarkFiles(); } } else { Log.d(LOG_TAG, "Not starting refresh; interval is 0)"); } } catch (IOException e) { Log.e(LOG_TAG, "Could not create new queue file; exiting", e); } } public void unregister(final Activity activity) { synchronized (lock){ if (LOG_DEBUG) { Log.d(LOG_TAG, "Unregistering activity: name=" + activity.getClass().getSimpleName()); } backgroundRefreshInterval = Integer.valueOf(PreferenceManager.getDefaultSharedPreferences(activity).getString(activity.getString(R.string.background_auto_refresh_time), "120")); if (refreshInterval == 0) { registrationHandler.postDelayed(stopScheduler, MAX_TIME_BETWEEN_REGISTER); } else { registrationHandler.postDelayed(backgroundScheduler, MAX_TIME_BETWEEN_REGISTER); } } } private void shutdown() { if (LOG_DEBUG) { Log.d(LOG_TAG, "[shutdown] Setting refresh active to false"); } refreshActive = false; backgrounded = true; if (refreshQueue != null) { if (LOG_DEBUG) { Log.d(LOG_TAG, "Clearing thread queue", new Exception()); } try { refreshQueue.close(); } catch (IOException e) { Log.e(LOG_TAG, "Caught exception while trying to close queue", e); } } if (refreshInterval == 0) { refreshScheduler.cancel(JOB_ID); if (LOG_DEBUG) { Log.d(LOG_TAG, "Stopping scheduler"); } } if (sequencer != null) { sequencer.shutdown(); } } @Override protected void finalize() throws Throwable { Log.d(LOG_TAG, "Refresh scheduler is being garbage collected"); super.finalize(); } public interface AutoRefreshListener { void onRefreshStart(Bundle b); void onRefreshComplete(Bundle b); } private static class RefreshMessageHandler extends Handler { final RefreshScheduler scheduler; public RefreshMessageHandler(RefreshScheduler scheduler) { this.scheduler = scheduler; } @Override public void handleMessage(Message msg) { super.handleMessage(msg); if (msg.arg1 == Sequencer.MSG_ADD_RAW) { if (msg.obj != null) { scheduler.addThreadSynchronized((ThreadInfo) msg.obj); } } else if (msg.arg1 == Sequencer.MSG_ADD_OBJ) { if (msg.obj != null) { scheduler.addThreadSynchronized((ThreadInfo) msg.obj); } } else if (msg.arg1 == Sequencer.MSG_REMOVE) { if (msg.obj != null) { ThreadInfo ti = (ThreadInfo) msg.obj; scheduler.removeThreadSynchronized(ti.boardName, ti.threadId); } } else if (msg.arg1 == Sequencer.MSG_UPDATE_INTERVAL) { scheduler.setIntervalSynchronized(msg.arg2); } else if (msg.arg1 == Sequencer.MSG_SCHEDULE_RUN) { scheduler.scheduleNextRunSynchronized(); } // else if (msg.arg1 == Sequencer.MSG_QUIT) { // getLooper().quit(); // } } } private class Sequencer extends Thread { static final int MSG_ADD_RAW = 0; static final int MSG_ADD_OBJ = 4; static final int MSG_REMOVE = 1; static final int MSG_UPDATE_INTERVAL = 2; static final int MSG_SCHEDULE_RUN = 3; static final int MSG_QUIT = 4; private Handler handler; private OnSequencerStartedCallback callback; boolean started = false; @Override public void run() { super.run(); Looper.prepare(); synchronized (this) { handler = new RefreshMessageHandler(RefreshScheduler.this); started = true; if (callback != null) { callback.onStart(); callback = null; } } Looper.loop(); } @Override public synchronized void start() { final long startTime = System.currentTimeMillis(); if (LOG_DEBUG) { Log.i(LOG_TAG, "Starting sequencer thread: time=" + startTime); } super.start(); if (LOG_DEBUG) { final long endTime = System.currentTimeMillis(); Log.i(LOG_TAG, "Sequencer started: time=" + endTime + ", elapsed=" + (endTime - startTime)); } } public void addThread(String boardName, long threadId, boolean watched) { ThreadInfo threadInfo = new ThreadInfo(threadId, boardName, null, watched); addThread(threadInfo); } public void addThread(final ThreadInfo threadInfo) { final Message msg = new Message(); msg.arg1 = MSG_ADD_OBJ; msg.obj = threadInfo; handler.sendMessage(msg); } public void removeThread(final String boardName, final long threadId) { ThreadInfo threadInfo = new ThreadInfo(threadId, boardName, "", false); final Message msg = new Message(); msg.arg1 = MSG_REMOVE; msg.obj = threadInfo; handler.sendMessage(msg); } public void updateInterval(final int interval) { final Message msg = new Message(); msg.arg1 = MSG_REMOVE; msg.arg2 = interval; handler.sendMessage(msg); } public void scheduleNextRun() { final Message msg = new Message(); msg.arg1 = MSG_SCHEDULE_RUN; handler.sendMessage(msg); } public void setOnSequencerStartedCallback(final OnSequencerStartedCallback callback) { this.callback = callback; } public void shutdown() { final Message msg = new Message(); msg.arg1 = MSG_QUIT; handler.sendMessage(msg); } } protected interface OnSequencerStartedCallback { void onStart(); } }