Refactor WatchManager

Use structured interval types: a handler for foreground update intervals and the alarmmanager for background update intervals.
Fix issue where the backoff would not work on pin watching.
Start of making all database calls optionally async and in a transaction.
Acquire a short wakelock while loading threads in the background.
Update the notifier with peeking and remove ticker and count support.
Shorten quotes and subject.
multisite
Floens 9 years ago
parent 66d3076fd7
commit 134d745bc5
  1. 50
      Clover/app/src/main/AndroidManifest.xml
  2. 2
      Clover/app/src/main/java/org/floens/chan/Chan.java
  3. 38
      Clover/app/src/main/java/org/floens/chan/chan/ChanLoader.java
  4. 162
      Clover/app/src/main/java/org/floens/chan/core/database/DatabaseManager.java
  5. 102
      Clover/app/src/main/java/org/floens/chan/core/database/DatabasePinManager.java
  6. 737
      Clover/app/src/main/java/org/floens/chan/core/manager/WatchManager.java
  7. 31
      Clover/app/src/main/java/org/floens/chan/core/model/Pin.java
  8. 2
      Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java
  9. 10
      Clover/app/src/main/java/org/floens/chan/core/presenter/ThreadPresenter.java
  10. 41
      Clover/app/src/main/java/org/floens/chan/core/receiver/WatchUpdateReceiver.java
  11. 8
      Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
  12. 197
      Clover/app/src/main/java/org/floens/chan/core/watch/PinWatcher.java
  13. 6
      Clover/app/src/main/java/org/floens/chan/ui/controller/DrawerController.java
  14. 14
      Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java
  15. 27
      Clover/app/src/main/java/org/floens/chan/ui/controller/WatchSettingsController.java
  16. 138
      Clover/app/src/main/java/org/floens/chan/ui/service/WatchNotifier.java
  17. 33
      Clover/app/src/main/java/org/floens/chan/ui/settings/ListSettingView.java
  18. 8
      Clover/app/src/main/res/values/strings.xml

@ -15,15 +15,15 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<manifest
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.floens.chan"
xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".ChanApplication"
@ -38,35 +38,45 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http"/>
<data android:scheme="https"/>
<data android:host="4chan.org"/>
<data android:host="www.4chan.org"/>
<data android:host="boards.4chan.org"/>
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="4chan.org" />
<data android:host="www.4chan.org" />
<data android:host="boards.4chan.org" />
</intent-filter>
</activity>
<activity android:name=".test.TestActivity"/>
<activity android:name=".test.TestActivity" />
<service
android:name=".ui.service.WatchNotifier"
android:exported="false"/>
android:exported="false" />
<service
android:name=".ui.service.SavingNotification"
android:exported="false"/>
android:exported="false" />
<receiver
android:name=".core.receiver.WatchUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="org.floens.chan.intent.action.WATCHER_UPDATE" />
</intent-filter>
</receiver>
</application>

@ -129,7 +129,7 @@ public class Chan extends Application {
databaseManager = new DatabaseManager(this);
boardManager = new BoardManager();
watchManager = new WatchManager(this);
watchManager = new WatchManager();
Time.endTiming("Initializing application", startTime);

@ -53,7 +53,7 @@ public class ChanLoader implements Response.ErrorListener, Response.Listener<Cha
private ChanReaderRequest request;
private int currentTimeout = -1;
private int currentTimeout = 0;
private int lastPostCount;
private long lastLoadTime;
private ScheduledFuture<?> pendingFuture;
@ -122,24 +122,25 @@ public class ChanLoader implements Response.ErrorListener, Response.Listener<Cha
/**
* Request more data
*
* @return true if a request was started, false otherwise
*/
public void requestMoreData() {
public boolean requestMoreData() {
clearPendingRunnable();
if (loadable.isThreadMode() && request == null) {
request = getData();
return true;
} else {
return false;
}
}
/**
* Request more data if the time left is below 0 If auto load more is
* disabled, this needs to be called manually. Otherwise this is called
* automatically when the timer hits 0.
* Request more data if {@link #getTimeUntilLoadMore()} is negative.
*/
public void loadMoreIfTime() {
if (getTimeUntilLoadMore() < 0L) {
requestMoreData();
}
public boolean loadMoreIfTime() {
return getTimeUntilLoadMore() < 0L && requestMoreData();
}
public void quickLoad() {
@ -216,6 +217,14 @@ public class ChanLoader implements Response.ErrorListener, Response.Listener<Cha
lastLoadTime = Time.get();
int postCount = thread.posts.size();
if (postCount > lastPostCount) {
lastPostCount = postCount;
currentTimeout = 0;
} else {
currentTimeout = Math.min(currentTimeout + 1, watchTimeouts.length - 1);
}
for (ChanLoaderCallback l : listeners) {
l.onChanLoaderData(thread);
}
@ -262,15 +271,6 @@ public class ChanLoader implements Response.ErrorListener, Response.Listener<Cha
public void setTimer() {
clearPendingRunnable();
int postCount = thread == null ? 0 : thread.posts.size();
if (postCount > lastPostCount) {
lastPostCount = postCount;
currentTimeout = 0;
} else {
currentTimeout++;
currentTimeout = Math.min(currentTimeout, watchTimeouts.length - 1);
}
int watchTimeout = watchTimeouts[currentTimeout];
Logger.d(TAG, "Scheduled reload in " + watchTimeout + "s");
@ -289,7 +289,7 @@ public class ChanLoader implements Response.ErrorListener, Response.Listener<Cha
}
public void clearTimer() {
currentTimeout = -1;
currentTimeout = 0;
clearPendingRunnable();
}

@ -18,6 +18,8 @@
package org.floens.chan.core.database;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import com.j256.ormlite.dao.Dao;
import com.j256.ormlite.misc.TransactionManager;
@ -28,7 +30,6 @@ import org.floens.chan.core.model.Board;
import org.floens.chan.core.model.Filter;
import org.floens.chan.core.model.History;
import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.model.Pin;
import org.floens.chan.core.model.Post;
import org.floens.chan.core.model.SavedReply;
import org.floens.chan.core.model.ThreadHide;
@ -69,8 +70,25 @@ public class DatabaseManager {
private final Object historyLock = new Object();
private final HashMap<Loadable, History> historyByLoadable = new HashMap<>();
private final DatabasePinManager databasePinManager;
public DatabaseManager(Context context) {
helper = new DatabaseHelper(context);
databasePinManager = new DatabasePinManager(this, helper);
initialize();
}
private void initialize() {
loadSavedReplies();
loadThreadHides();
loadHistory();
}
/**
* Reset all tables in the database. Used for the developer screen.
*/
public void reset() {
helper.reset();
initialize();
}
@ -127,91 +145,8 @@ public class DatabaseManager {
return getSavedReply(board, no) != null;
}
/**
* Adds a {@link Pin} to the pin table.
*
* @param pin Pin to save
*/
public void addPin(Pin pin) {
try {
helper.loadableDao.create(pin.loadable);
helper.pinDao.create(pin);
} catch (SQLException e) {
Logger.e(TAG, "Error adding pin to db", e);
}
}
/**
* Deletes a {@link Pin} from the pin table.
*
* @param pin Pin to delete
*/
public void removePin(Pin pin) {
try {
helper.pinDao.delete(pin);
helper.loadableDao.delete(pin.loadable);
} catch (SQLException e) {
Logger.e(TAG, "Error removing pin from db", e);
}
}
/**
* Updates a {@link Pin} in the pin table.
*
* @param pin Pin to update
*/
public void updatePin(Pin pin) {
try {
helper.pinDao.update(pin);
helper.loadableDao.update(pin.loadable);
} catch (SQLException e) {
Logger.e(TAG, "Error updating pin in db", e);
}
}
/**
* Updates all {@link Pin}s in the list to the pin table.
*
* @param pins Pins to update
*/
public void updatePins(final List<Pin> pins) {
try {
callInTransaction(helper.getConnectionSource(), new Callable<Void>() {
@Override
public Void call() throws SQLException {
for (Pin pin : pins) {
helper.pinDao.update(pin);
}
for (Pin pin : pins) {
helper.loadableDao.update(pin.loadable);
}
return null;
}
});
} catch (Exception e) {
Logger.e(TAG, "Error updating pins in db", e);
}
}
/**
* Get a list of {@link Pin}s from the pin table.
*
* @return List of Pins
*/
public List<Pin> getPinned() {
List<Pin> list = null;
try {
list = helper.pinDao.queryForAll();
for (Pin p : list) {
helper.loadableDao.refresh(p.loadable);
}
} catch (SQLException e) {
Logger.e(TAG, "Error getting pins from db", e);
}
return list;
public DatabasePinManager getDatabasePinManager() {
return databasePinManager;
}
/**
@ -431,20 +366,6 @@ public class DatabaseManager {
return o;
}
/**
* Reset all tables in the database. Used for the developer screen.
*/
public void reset() {
helper.reset();
initialize();
}
private void initialize() {
loadSavedReplies();
loadThreadHides();
loadHistory();
}
/**
* Threadsafe.
*/
@ -541,4 +462,45 @@ public class DatabaseManager {
Logger.e(TAG, "Error trimming table " + table, e);
}
}
public <T> void runTask(final Callable<T> taskCallable) {
runTask(taskCallable, null);
}
public <T> void runTask(final Callable<T> taskCallable, final TaskResult<T> taskResult) {
backgroundExecutor.submit(new Runnable() {
@Override
public void run() {
try {
T result = TransactionManager.callInTransaction(helper.getConnectionSource(), taskCallable);
if (taskResult != null) {
completeTask(taskResult, result);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
}
public <T> T runTaskSync(final Callable<T> taskCallable) {
try {
return TransactionManager.callInTransaction(helper.getConnectionSource(), taskCallable);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public <T> void completeTask(final TaskResult<T> task, final T result) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
task.onComplete(result);
}
});
}
public interface TaskResult<T> {
void onComplete(T result);
}
}

@ -0,0 +1,102 @@
/*
* Clover - 4chan browser https://github.com/Floens/Clover/
* Copyright (C) 2014 Floens
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.floens.chan.core.database;
import org.floens.chan.core.model.Pin;
import java.util.List;
import java.util.concurrent.Callable;
public class DatabasePinManager {
private static final String TAG = "DatabasePinManager";
private DatabaseManager databaseManager;
private DatabaseHelper helper;
public DatabasePinManager(DatabaseManager databaseManager, DatabaseHelper helper) {
this.databaseManager = databaseManager;
this.helper = helper;
}
public Callable<Pin> createPin(final Pin pin) {
return new Callable<Pin>() {
@Override
public Pin call() throws Exception {
helper.loadableDao.create(pin.loadable);
helper.pinDao.create(pin);
return pin;
}
};
}
public Callable<Void> deletePin(final Pin pin) {
return new Callable<Void>() {
@Override
public Void call() throws Exception {
helper.pinDao.delete(pin);
helper.loadableDao.delete(pin.loadable);
return null;
}
};
}
public Callable<Pin> updatePin(final Pin pin) {
return new Callable<Pin>() {
@Override
public Pin call() throws Exception {
helper.pinDao.update(pin);
helper.loadableDao.update(pin.loadable);
return pin;
}
};
}
public Callable<List<Pin>> updatePins(final List<Pin> pins) {
return new Callable<List<Pin>>() {
@Override
public List<Pin> call() throws Exception {
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
helper.pinDao.update(pin);
}
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
helper.loadableDao.update(pin.loadable);
}
return null;
}
};
}
public Callable<List<Pin>> getPins() {
return new Callable<List<Pin>>() {
@Override
public List<Pin> call() throws Exception {
List<Pin> list = helper.pinDao.queryForAll();
for (int i = 0; i < list.size(); i++) {
Pin p = list.get(i);
helper.loadableDao.refresh(p.loadable);
}
return list;
}
};
}
}

@ -17,34 +17,89 @@
*/
package org.floens.chan.core.manager;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.PowerManager;
import com.android.volley.VolleyError;
import org.floens.chan.Chan;
import org.floens.chan.chan.ChanLoader;
import org.floens.chan.core.database.DatabaseManager;
import org.floens.chan.core.database.DatabasePinManager;
import org.floens.chan.core.model.ChanThread;
import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.model.Pin;
import org.floens.chan.core.model.Post;
import org.floens.chan.core.pool.LoadablePool;
import org.floens.chan.core.pool.LoaderPool;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.ui.helper.PostHelper;
import org.floens.chan.ui.service.WatchNotifier;
import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import de.greenrobot.event.EventBus;
import static org.floens.chan.utils.AndroidUtils.getAppContext;
/**
* Manages all Pin related management.
* <p/>
* <p>Pins are threads that are pinned to a pane on the left.
* <p/>
* <p>The pin watcher is an optional feature that watches threads for new posts and displays a new
* post counter next to the pin view. Watching happens with the same backoff timer as used for
* the auto updater for open threads.
* <p/>
* <p>Background watching is a feature that can be enabled. With background watching enabled then
* the PinManager will register an AlarmManager to check for updates in intervals. It will acquire
* a wakelock shortly while checking for updates.
* <p/>
* <p>All pin adding and removing must go through this class to properly update the watchers.
*/
public class WatchManager {
private static final String TAG = "WatchManager";
private static final int FOREGROUND_TIME = 5;
private enum IntervalType {
/**
* A timer that uses a {@link Handler} that calls {@link #update(boolean)} every {@value #FOREGROUND_INTERVAL}ms.
*/
FOREGROUND,
/**
* A timer that schedules a broadcast to be send that calls {@link #update(boolean)}.
*/
BACKGROUND,
/**
* No scheduling.
*/
NONE
}
public static final int DEFAULT_BACKGROUND_INTERVAL = 15 * 60 * 1000;
private static final long FOREGROUND_INTERVAL = 15 * 1000;
private static final int MESSAGE_UPDATE = 1;
private static final int REQUEST_CODE_WATCH_UPDATE = 2;
private static final String WATCHER_UPDATE_ACTION = "org.floens.chan.intent.action.WATCHER_UPDATE";
private static final String WAKELOCK_TAG = "WatchManagerUpdateLock";
private static final long WAKELOCK_MAX_TIME = 60 * 1000;
private static final long BACKGROUND_UPDATE_MIN_DELAY = 90 * 1000;
private static final Comparator<Pin> SORT_PINS = new Comparator<Pin>() {
@Override
@ -53,74 +108,61 @@ public class WatchManager {
}
};
private final Context context;
private final List<Pin> pins;
private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
private PendingTimer pendingTimer;
private final AlarmManager alarmManager;
private final PowerManager powerManager;
public WatchManager(Context context) {
this.context = context;
private final List<Pin> pins;
private final DatabaseManager databaseManager;
private final DatabasePinManager databasePinManager;
pins = Chan.getDatabaseManager().getPinned();
Collections.sort(pins, SORT_PINS);
private final Handler handler;
EventBus.getDefault().register(this);
private IntervalType currentInterval = IntervalType.NONE;
updateTimerState(true);
updateNotificationServiceState();
updatePinWatchers();
}
private Map<Pin, PinWatcher> pinWatchers = new HashMap<>();
/**
* Look for a pin that has an loadable that is equal to the supplied
* loadable.
*
* @param other
* @return The pin whose loadable is equal to the supplied loadable, or null
* if no pin was found.
*/
public Pin findPinByLoadable(Loadable other) {
for (Pin pin : pins) {
if (pin.loadable.equals(other)) {
return pin;
}
}
private Set<PinWatcher> waitingForPinWatchersForBackgroundUpdate;
private PowerManager.WakeLock wakeLock;
private long lastBackgroundUpdateTime;
return null;
}
public WatchManager() {
alarmManager = (AlarmManager) getAppContext().getSystemService(Context.ALARM_SERVICE);
powerManager = (PowerManager) getAppContext().getSystemService(Context.POWER_SERVICE);
public Pin findPinById(int id) {
for (Pin pin : pins) {
if (pin.id == id) {
return pin;
}
}
databaseManager = Chan.getDatabaseManager();
databasePinManager = databaseManager.getDatabasePinManager();
pins = databaseManager.runTaskSync(databasePinManager.getPins());
Collections.sort(pins, SORT_PINS);
return null;
handler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MESSAGE_UPDATE) {
update(false);
return true;
} else {
return false;
}
public List<Pin> getPins() {
return pins;
}
});
public List<Pin> getWatchingPins() {
if (ChanSettings.watchEnabled.get()) {
List<Pin> l = new ArrayList<>();
EventBus.getDefault().register(this);
for (Pin p : pins) {
if (p.watching)
l.add(p);
updateState();
}
return l;
} else {
return Collections.emptyList();
}
public boolean createPin(Loadable loadable, Post opPost) {
Pin pin = new Pin();
pin.loadable = loadable;
pin.loadable.title = PostHelper.getTitle(opPost, loadable);
pin.thumbnailUrl = opPost.thumbnailUrl;
return createPin(pin);
}
public boolean addPin(Pin pin) {
public boolean createPin(Pin pin) {
// No duplicates
for (Pin e : pins) {
for (int i = 0; i < pins.size(); i++) {
Pin e = pins.get(i);
if (e.loadable.equals(pin.loadable)) {
return false;
}
@ -128,233 +170,377 @@ public class WatchManager {
pin.order = pins.size();
pins.add(pin);
Chan.getDatabaseManager().addPin(pin);
databaseManager.runTaskSync(databasePinManager.createPin(pin));
onPinsChanged();
updateState();
EventBus.getDefault().post(new PinAddedMessage(pin));
return true;
}
public boolean addPin(Post opPost) {
Pin pin = new Pin();
pin.loadable = LoadablePool.getInstance().obtain(new Loadable(opPost.board, opPost.no));
pin.loadable.title = PostHelper.getTitle(opPost, pin.loadable);
pin.thumbnailUrl = opPost.thumbnailUrl;
return addPin(pin);
}
public void deletePin(Pin pin) {
pins.remove(pin);
public boolean addPin(Loadable loadable, Post opPost) {
Pin pin = new Pin();
pin.loadable = loadable;
pin.loadable.title = PostHelper.getTitle(opPost, loadable);
pin.thumbnailUrl = opPost.thumbnailUrl;
return addPin(pin);
}
destroyPinWatcher(pin);
public void removePin(Pin pin) {
pins.remove(pin);
pin.destroyWatcher();
Chan.getDatabaseManager().removePin(pin);
databaseManager.runTaskSync(databasePinManager.deletePin(pin));
// Update the new orders
updateDatabase();
updatePinsInDatabase();
onPinsChanged();
updateState();
EventBus.getDefault().post(new PinRemovedMessage(pin));
}
/**
* Update the pin in the database
*
* @param pin
*/
public void updatePin(Pin pin) {
Chan.getDatabaseManager().updatePin(pin);
databaseManager.runTaskSync(databasePinManager.updatePin(pin));
onPinsChanged();
updateState();
EventBus.getDefault().post(new PinChangedMessage(pin));
}
/**
* Updates all the pins to the database.
*/
public void updateDatabase() {
Chan.getDatabaseManager().updatePins(pins);
public Pin findPinByLoadable(Loadable other) {
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
if (pin.loadable.equals(other)) {
return pin;
}
}
return null;
}
public Pin findPinById(int id) {
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
if (pin.id == id) {
return pin;
}
}
return null;
}
public List<Pin> getAllPins() {
return pins;
}
public List<Pin> getWatchingPins() {
if (isWatchingSettingEnabled()) {
List<Pin> l = new ArrayList<>();
for (int i = 0; i < pins.size(); i++) {
Pin p = pins.get(i);
if (p.watching)
l.add(p);
}
return l;
} else {
return Collections.emptyList();
}
}
public void toggleWatch(Pin pin) {
pin.watching = !pin.watching;
updateState();
EventBus.getDefault().post(new PinChangedMessage(pin));
onPinsChanged();
invokeLoadNow();
}
public void pinWatcherUpdated(Pin pin) {
EventBus.getDefault().post(new PinChangedMessage(pin));
onPinsChanged();
public void onBottomPostViewed(Pin pin) {
if (pin.watchNewCount >= 0) {
pin.watchLastCount = pin.watchNewCount;
}
if (pin.quoteNewCount >= 0) {
pin.quoteLastCount = pin.quoteNewCount;
}
public void onPinsChanged() {
updateTimerState(false);
updateNotificationServiceState();
updatePinWatchers();
PinWatcher pinWatcher = getPinWatcher(pin);
if (pinWatcher != null) {
pinWatcher.onViewed();
}
public void invokeLoadNow() {
if (pendingTimer != null) {
pendingTimer.cancel();
pendingTimer = null;
Logger.d(TAG, "Canceled timer");
updatePin(pin);
}
// Called when the app changes foreground state
public void onEvent(Chan.ForegroundChangedMessage message) {
updateState();
if (!message.inForeground) {
updatePinsInDatabase();
}
}
updateTimerState(true);
// Called when the broadcast scheduled by the alarmmanager was received
public void onBroadcastReceived() {
if (currentInterval != IntervalType.BACKGROUND) {
Logger.e(TAG, "Received a broadcast for a watchmanager update, but the current state is not BACKGROUND. Ignoring the broadcast.");
} else if (System.currentTimeMillis() - lastBackgroundUpdateTime < BACKGROUND_UPDATE_MIN_DELAY) {
Logger.w(TAG, "Background update broadcast ignored because it was requested too soon");
} else {
lastBackgroundUpdateTime = System.currentTimeMillis();
update(true);
}
}
public void pausePins() {
// Called from the button on the notification
public void pauseAll() {
List<Pin> watchingPins = getWatchingPins();
for (Pin pin : watchingPins) {
for (int i = 0; i < watchingPins.size(); i++) {
Pin pin = watchingPins.get(i);
pin.watching = false;
}
onPinsChanged();
updateDatabase();
updateState();
updatePinsInDatabase();
for (Pin pin : getPins()) {
List<Pin> allPins = getAllPins();
for (int i = 0; i < allPins.size(); i++) {
Pin pin = allPins.get(i);
EventBus.getDefault().post(new PinChangedMessage(pin));
}
}
public void onEvent(Chan.ForegroundChangedMessage message) {
updateNotificationServiceState();
updateTimerState(true);
if (!message.inForeground) {
updateDatabase();
}
}
// Called when the user changes the watch enabled preference
public void onWatchEnabledChanged(boolean watchEnabled) {
updateNotificationServiceState(watchEnabled, getWatchBackgroundEnabled());
updateTimerState(watchEnabled, getWatchBackgroundEnabled(), false);
updatePinWatchers(watchEnabled);
for (Pin pin : getPins()) {
updateState(watchEnabled, isBackgroundWatchingSettingEnabled());
List<Pin> pins = getAllPins();
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
EventBus.getDefault().post(new PinChangedMessage(pin));
}
}
// Called when the user changes the watch background enabled preference
public void onBackgroundWatchingChanged(boolean backgroundEnabled) {
updateNotificationServiceState(getTimerEnabled(), backgroundEnabled);
updateTimerState(getTimerEnabled(), backgroundEnabled, false);
for (Pin pin : getPins()) {
updateState(isTimerEnabled(), backgroundEnabled);
List<Pin> pins = getAllPins();
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
EventBus.getDefault().post(new PinChangedMessage(pin));
}
}
private boolean getTimerEnabled() {
// getWatchingPins returns an empty list when ChanPreferences.getWatchEnabled() is false
return getWatchingPins().size() > 0;
public PinWatcher getPinWatcher(Pin pin) {
return pinWatchers.get(pin);
}
public boolean getWatchBackgroundEnabled() {
return ChanSettings.watchBackground.get();
private boolean createPinWatcher(Pin pin) {
if (!pinWatchers.containsKey(pin)) {
pinWatchers.put(pin, new PinWatcher(pin));
return true;
} else {
return false;
}
}
private void updatePinWatchers() {
updatePinWatchers(ChanSettings.watchEnabled.get());
private boolean destroyPinWatcher(Pin pin) {
PinWatcher pinWatcher = pinWatchers.remove(pin);
if (pinWatcher != null) {
pinWatcher.destroy();
}
return pinWatcher != null;
}
private void updatePinWatchers(boolean watchEnabled) {
for (Pin pin : pins) {
if (watchEnabled) {
pin.createWatcher();
} else {
pin.destroyWatcher();
private void updatePinsInDatabase() {
databaseManager.runTask(databasePinManager.updatePins(pins));
}
private Boolean isWatchingSettingEnabled() {
return ChanSettings.watchEnabled.get();
}
private boolean isBackgroundWatchingSettingEnabled() {
return ChanSettings.watchBackground.get();
}
private void updateNotificationServiceState() {
updateNotificationServiceState(getTimerEnabled(), getWatchBackgroundEnabled());
private boolean isInForeground() {
return Chan.getInstance().getApplicationInForeground();
}
private void updateNotificationServiceState(boolean watchEnabled, boolean backgroundEnabled) {
if (watchEnabled && backgroundEnabled) {
// Also calls onStartCommand, which updates the notification
context.startService(new Intent(context, WatchNotifier.class));
} else {
context.stopService(new Intent(context, WatchNotifier.class));
private boolean isTimerEnabled() {
return !getWatchingPins().isEmpty();
}
private int getBackgroundIntervalSetting() {
return ChanSettings.watchBackgroundInterval.get();
}
private void updateTimerState(boolean invokeLoadNow) {
updateTimerState(getTimerEnabled(), getWatchBackgroundEnabled(), invokeLoadNow);
private void updateState() {
updateState(isTimerEnabled(), isBackgroundWatchingSettingEnabled());
}
private void updateTimerState(boolean watchEnabled, boolean backgroundEnabled, boolean invokeLoadNow) {
Logger.d(TAG, "updateTimerState watchEnabled=" + watchEnabled + " backgroundEnabled=" + backgroundEnabled + " invokeLoadNow=" + invokeLoadNow + " foreground=" + Chan.getInstance().getApplicationInForeground());
if (watchEnabled) {
if (Chan.getInstance().getApplicationInForeground()) {
setTimer(invokeLoadNow ? 1 : FOREGROUND_TIME);
// Update the interval type, according to the current settings,
// creates and destroys the PinWatchers where needed and
// updates the notification.
private void updateState(boolean watchEnabled, boolean backgroundEnabled) {
Logger.d(TAG, "updateState watchEnabled=" + watchEnabled + " backgroundEnabled=" + backgroundEnabled + " foreground=" + isInForeground());
IntervalType intervalType;
if (!watchEnabled) {
intervalType = IntervalType.NONE;
} else {
if (isInForeground()) {
intervalType = IntervalType.FOREGROUND;
} else {
if (backgroundEnabled) {
setTimer(Integer.parseInt(ChanSettings.watchBackgroundTimeout.get()));
intervalType = IntervalType.BACKGROUND;
} else {
if (pendingTimer != null) {
pendingTimer.cancel();
pendingTimer = null;
Logger.d(TAG, "Canceled timer");
}
intervalType = IntervalType.NONE;
}
}
}
// Changing interval type, like when watching is disabled or the app goes to the background
if (currentInterval != intervalType) {
// Handle the preview state
switch (currentInterval) {
case FOREGROUND:
// Stop receiving handler messages
handler.removeMessages(MESSAGE_UPDATE);
break;
case BACKGROUND:
// Stop the scheduled broadcast
scheduleAlarmManager(false);
break;
case NONE:
// Nothing to do when no timer was set.
break;
}
Logger.d(TAG, "Setting interval type from " + currentInterval.name() + " to " + intervalType.name());
currentInterval = intervalType;
switch (currentInterval) {
case FOREGROUND:
// Schedule a delayed handler that will call update(false)
handler.sendMessageDelayed(handler.obtainMessage(MESSAGE_UPDATE), FOREGROUND_INTERVAL);
break;
case BACKGROUND:
// Schedule an intervaled broadcast receiver
scheduleAlarmManager(true);
break;
case NONE:
// Nothing to do when no timer is set.
break;
}
}
// Update pin watchers
for (int i = 0; i < pins.size(); i++) {
Pin pin = pins.get(i);
if (watchEnabled && pin.watching) {
createPinWatcher(pin);
} else {
destroyPinWatcher(pin);
}
}
// Update notification state
if (watchEnabled && backgroundEnabled) {
// Also calls onStartCommand, which updates the notification with new info
getAppContext().startService(new Intent(getAppContext(), WatchNotifier.class));
} else {
if (pendingTimer != null) {
pendingTimer.cancel();
pendingTimer = null;
Logger.d(TAG, "Canceled timer");
getAppContext().stopService(new Intent(getAppContext(), WatchNotifier.class));
}
}
// Schedule a broadcast that calls WatchUpdateReceiver.onReceive() if enabled is true,
// and unschedules it when false
private void scheduleAlarmManager(boolean enable) {
Intent intent = new Intent(WATCHER_UPDATE_ACTION);
PendingIntent pendingIntent = PendingIntent.getBroadcast(getAppContext(), REQUEST_CODE_WATCH_UPDATE, intent, 0);
if (enable) {
int interval = getBackgroundIntervalSetting();
Logger.d(TAG, "Scheduled for an inexact repeating broadcast receiver with an interval of " + (interval / 1000 / 60) + " minutes");
alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, interval, interval, pendingIntent);
} else {
Logger.d(TAG, "Unscheduled the repeating broadcast receiver");
alarmManager.cancel(pendingIntent);
}
}
private void setTimer(int time) {
if (pendingTimer != null && pendingTimer.time == time) {
return;
// Update the watching pins
private void update(boolean fromBackground) {
Logger.d(TAG, "update() fromBackground = " + fromBackground);
switch (currentInterval) {
case FOREGROUND:
// reschedule handler message
handler.sendMessageDelayed(handler.obtainMessage(MESSAGE_UPDATE), FOREGROUND_INTERVAL);
break;
case BACKGROUND:
// AlarmManager is scheduled as an interval
break;
}
if (pendingTimer != null) {
pendingTimer.cancel();
pendingTimer = null;
Logger.d(TAG, "Canceled timer");
// A set of watchers that all have to complete being updated
// before the wakelock is released again
waitingForPinWatchersForBackgroundUpdate = null;
if (fromBackground) {
waitingForPinWatchersForBackgroundUpdate = new HashSet<>();
}
ScheduledFuture scheduledFuture = executor.schedule(new Runnable() {
@Override
public void run() {
AndroidUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
timerFired();
List<Pin> watchingPins = getWatchingPins();
for (int i = 0; i < watchingPins.size(); i++) {
Pin pin = watchingPins.get(i);
PinWatcher pinWatcher = getPinWatcher(pin);
if (pinWatcher != null && pinWatcher.update(fromBackground)) {
EventBus.getDefault().post(new PinChangedMessage(pin));
if (fromBackground) {
waitingForPinWatchersForBackgroundUpdate.add(pinWatcher);
}
});
}
}, time, TimeUnit.SECONDS);
pendingTimer = new PendingTimer(scheduledFuture, time);
Logger.d(TAG, "Timer firing in " + time + " seconds");
}
private void timerFired() {
Logger.d(TAG, "Timer fired");
pendingTimer = null;
if (fromBackground && !waitingForPinWatchersForBackgroundUpdate.isEmpty()) {
Logger.i(TAG, "Acquiring wakelock for pin watcher updates");
manageLock(true);
}
}
private void pinWatcherUpdated(PinWatcher pinWatcher) {
updateState();
EventBus.getDefault().post(new PinChangedMessage(pinWatcher.pin));
for (Pin pin : getWatchingPins()) {
if (pin.update()) {
EventBus.getDefault().post(new PinChangedMessage(pin));
if (waitingForPinWatchersForBackgroundUpdate != null) {
waitingForPinWatchersForBackgroundUpdate.remove(pinWatcher);
if (waitingForPinWatchersForBackgroundUpdate.isEmpty()) {
Logger.i(TAG, "All watchers updated, removing wakelock");
waitingForPinWatchersForBackgroundUpdate = null;
manageLock(false);
}
}
}
updateTimerState(false);
private void manageLock(boolean lock) {
if (lock) {
if (wakeLock != null) {
Logger.e(TAG, "Wakelock not null while trying to acquire one");
if (wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG);
wakeLock.setReferenceCounted(false);
wakeLock.acquire(WAKELOCK_MAX_TIME);
} else {
if (wakeLock == null) {
Logger.e(TAG, "Wakelock null while trying to release it");
} else {
if (wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
}
}
public static class PinAddedMessage {
@ -381,17 +567,172 @@ public class WatchManager {
}
}
private static class PendingTimer {
public ScheduledFuture scheduledFuture;
public int time;
public class PinWatcher implements ChanLoader.ChanLoaderCallback {
private static final String TAG = "PinWatcher";
private final Pin pin;
private ChanLoader chanLoader;
private final List<Post> posts = new ArrayList<>();
private final List<Post> quotes = new ArrayList<>();
private boolean wereNewQuotes = false;
private boolean wereNewPosts = false;
public PinWatcher(Pin pin) {
this.pin = pin;
Logger.i(TAG, "PinWatcher: created for " + pin);
chanLoader = LoaderPool.getInstance().obtain(pin.loadable, this);
}
public List<Post> getUnviewedPosts() {
if (posts.size() == 0) {
return posts;
} else {
return posts.subList(Math.max(0, posts.size() - pin.getNewPostCount()), posts.size());
}
}
public List<Post> getUnviewedQuotes() {
return quotes.subList(Math.max(0, quotes.size() - pin.getNewQuoteCount()), quotes.size());
}
public boolean getWereNewQuotes() {
if (wereNewQuotes) {
wereNewQuotes = false;
return true;
} else {
return false;
}
}
public boolean getWereNewPosts() {
if (wereNewPosts) {
wereNewPosts = false;
return true;
} else {
return false;
}
}
private void destroy() {
if (chanLoader != null) {
Logger.i(TAG, "PinWatcher: destroyed for " + pin);
LoaderPool.getInstance().release(chanLoader, this);
chanLoader = null;
}
}
private void onViewed() {
wereNewPosts = false;
wereNewQuotes = false;
}
private boolean update(boolean fromBackground) {
if (!pin.isError && pin.watching) {
if (fromBackground) {
// Always load regardless of timer, since the time left is not accurate for 15min+ intervals
chanLoader.requestMoreData();
return true;
} else {
// true if a load was started
return chanLoader.loadMoreIfTime();
}
} else {
return false;
}
}
/*private long getTimeUntilNextLoad() {
return chanLoader.getTimeUntilLoadMore();
}*/
/*public boolean isLoading() {
return chanLoader.isLoading();
}*/
@Override
public void onChanLoaderError(VolleyError error) {
pin.isError = true;
pin.watching = false;
pinWatcherUpdated(this);
}
@Override
public void onChanLoaderData(ChanThread thread) {
pin.isError = false;
if (pin.thumbnailUrl == null && thread.op != null && thread.op.hasImage) {
pin.thumbnailUrl = thread.op.thumbnailUrl;
}
// Populate posts list
posts.clear();
posts.addAll(thread.posts);
// Populate quotes list
quotes.clear();
public PendingTimer(ScheduledFuture scheduledFuture, int time) {
this.scheduledFuture = scheduledFuture;
this.time = time;
// Get list of saved replies from this thread
List<Post> savedReplies = new ArrayList<>();
for (Post item : thread.posts) {
// saved.title = pin.loadable.title;
if (item.isSavedReply) {
savedReplies.add(item);
}
}
// Now get a list of posts that have a quote to a saved reply
for (Post post : thread.posts) {
for (Post saved : savedReplies) {
if (post.repliesTo.contains(saved.no)) {
quotes.add(post);
}
}
}
boolean isFirstLoad = pin.watchNewCount < 0 || pin.quoteNewCount < 0;
// If it was more than before processing
int lastWatchNewCount = pin.watchNewCount;
int lastQuoteNewCount = pin.quoteNewCount;
if (isFirstLoad) {
pin.watchLastCount = posts.size();
pin.quoteLastCount = quotes.size();
}
pin.watchNewCount = posts.size();
pin.quoteNewCount = quotes.size();
if (!isFirstLoad) {
// There were new posts after processing
if (pin.watchNewCount > lastWatchNewCount) {
wereNewPosts = true;
}
// There were new quotes after processing
if (pin.quoteNewCount > lastQuoteNewCount) {
wereNewQuotes = true;
}
}
if (Logger.debugEnabled()) {
Logger.d(TAG, String.format(Locale.ENGLISH,
"postlast=%d postnew=%d werenewposts=%b quotelast=%d quotenew=%d werenewquotes=%b nextload=%ds",
pin.watchLastCount, pin.watchNewCount, wereNewPosts, pin.quoteLastCount,
pin.quoteNewCount, wereNewQuotes, chanLoader.getTimeUntilLoadMore() / 1000));
}
if (thread.archived || thread.closed) {
pin.archived = true;
pin.watching = false;
}
public void cancel() {
scheduledFuture.cancel(false);
pinWatcherUpdated(this);
}
}
}

@ -20,8 +20,6 @@ package org.floens.chan.core.model;
import com.j256.ormlite.field.DatabaseField;
import com.j256.ormlite.table.DatabaseTable;
import org.floens.chan.core.watch.PinWatcher;
@DatabaseTable
public class Pin {
@DatabaseField(generatedId = true)
@ -57,8 +55,6 @@ public class Pin {
@DatabaseField
public boolean archived = false;
private PinWatcher pinWatcher;
public int getNewPostCount() {
if (watchLastCount < 0 || watchNewCount < 0) {
return 0;
@ -74,31 +70,4 @@ public class Pin {
return Math.max(0, quoteNewCount - quoteLastCount);
}
}
public PinWatcher getPinWatcher() {
return pinWatcher;
}
public void onBottomPostViewed() {
if (pinWatcher != null) {
pinWatcher.onViewed();
}
}
public boolean update() {
return pinWatcher != null && watching && pinWatcher.update();
}
public void createWatcher() {
if (pinWatcher == null) {
pinWatcher = new PinWatcher(this);
}
}
public void destroyWatcher() {
if (pinWatcher != null) {
pinWatcher.destroy();
pinWatcher = null;
}
}
}

@ -211,7 +211,7 @@ public class ReplyPresenter implements ReplyManager.HttpCallback<ReplyHttpCall>,
if (ChanSettings.postPinThread.get() && loadable.isThreadMode()) {
ChanThread thread = callback.getThread();
if (thread != null) {
watchManager.addPin(loadable, thread.op);
watchManager.createPin(loadable, thread.op);
}
}

@ -162,10 +162,10 @@ public class ThreadPresenter implements ChanLoader.ChanLoaderCallback, PostAdapt
if (pin == null) {
if (chanLoader.getThread() != null) {
Post op = chanLoader.getThread().op;
watchManager.addPin(loadable, op);
watchManager.createPin(loadable, op);
}
} else {
watchManager.removePin(pin);
watchManager.deletePin(pin);
}
return isPinned();
}
@ -275,8 +275,7 @@ public class ThreadPresenter implements ChanLoader.ChanLoaderCallback, PostAdapt
Pin pin = watchManager.findPinByLoadable(loadable);
if (pin != null) {
pin.onBottomPostViewed();
watchManager.updatePin(pin);
watchManager.onBottomPostViewed(pin);
}
threadPresenterCallback.showNewPostsNotification(false, -1);
@ -449,7 +448,8 @@ public class ThreadPresenter implements ChanLoader.ChanLoaderCallback, PostAdapt
databaseManager.saveReply(new SavedReply(post.board, post.no, "foo"));
break;
case POST_OPTION_PIN:
watchManager.addPin(post);
Loadable pinLoadable = LoadablePool.getInstance().obtain(new Loadable(post.board, post.no));
watchManager.createPin(pinLoadable, post);
break;
case POST_OPTION_OPEN_BROWSER:
AndroidUtils.openLink(

@ -0,0 +1,41 @@
/*
* Clover - 4chan browser https://github.com/Floens/Clover/
* Copyright (C) 2014 Floens
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.floens.chan.core.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.floens.chan.Chan;
import org.floens.chan.core.manager.WatchManager;
import org.floens.chan.utils.Logger;
public class WatchUpdateReceiver extends BroadcastReceiver {
private static final String TAG = "WatchUpdateReceiver";
private final WatchManager watchManager;
public WatchUpdateReceiver() {
watchManager = Chan.getWatchManager();
}
@Override
public void onReceive(Context context, Intent intent) {
watchManager.onBroadcastReceived();
}
}

@ -24,6 +24,7 @@ import android.text.TextUtils;
import org.floens.chan.Chan;
import org.floens.chan.R;
import org.floens.chan.chan.ChanUrls;
import org.floens.chan.core.manager.WatchManager;
import org.floens.chan.ui.adapter.PostsFilter;
import org.floens.chan.ui.cell.PostCellInterface;
import org.floens.chan.utils.AndroidUtils;
@ -100,9 +101,10 @@ public class ChanSettings {
public static final BooleanSetting watchEnabled;
public static final BooleanSetting watchCountdown;
public static final BooleanSetting watchBackground;
public static final StringSetting watchBackgroundTimeout;
public static final IntegerSetting watchBackgroundInterval;
public static final StringSetting watchNotifyMode;
public static final StringSetting watchSound;
public static final BooleanSetting watchPeek;
public static final StringSetting watchLed;
public static final StringSetting passToken;
@ -184,9 +186,10 @@ public class ChanSettings {
Chan.getWatchManager().onBackgroundWatchingChanged(value);
}
});
watchBackgroundTimeout = new StringSetting(p, "preference_watch_background_timeout", "60");
watchBackgroundInterval = new IntegerSetting(p, "preference_watch_background_interval", WatchManager.DEFAULT_BACKGROUND_INTERVAL);
watchNotifyMode = new StringSetting(p, "preference_watch_notify_mode", "all");
watchSound = new StringSetting(p, "preference_watch_sound", "quotes");
watchPeek = new BooleanSetting(p, "preference_watch_peek", true);
watchLed = new StringSetting(p, "preference_watch_led", "ffffffff");
passToken = new StringSetting(p, "preference_pass_token", "");
@ -227,6 +230,7 @@ public class ChanSettings {
// preference_board_editor_filler default false
// preference_pass_enabled default false
// preference_autoplay false
// preference_watch_background_timeout "60" the old timeout background setting in minutes
}
public static boolean passLoggedIn() {

@ -1,197 +0,0 @@
/*
* Clover - 4chan browser https://github.com/Floens/Clover/
* Copyright (C) 2014 Floens
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.floens.chan.core.watch;
import com.android.volley.VolleyError;
import org.floens.chan.Chan;
import org.floens.chan.chan.ChanLoader;
import org.floens.chan.core.model.ChanThread;
import org.floens.chan.core.model.Pin;
import org.floens.chan.core.model.Post;
import org.floens.chan.core.pool.LoaderPool;
import org.floens.chan.utils.Logger;
import java.util.ArrayList;
import java.util.List;
public class PinWatcher implements ChanLoader.ChanLoaderCallback {
private static final String TAG = "PinWatcher";
private final Pin pin;
private ChanLoader chanLoader;
private final List<Post> posts = new ArrayList<>();
private final List<Post> quotes = new ArrayList<>();
private boolean wereNewQuotes = false;
private boolean wereNewPosts = false;
public PinWatcher(Pin pin) {
this.pin = pin;
chanLoader = LoaderPool.getInstance().obtain(pin.loadable, this);
}
public void destroy() {
if (chanLoader != null) {
LoaderPool.getInstance().release(chanLoader, this);
chanLoader = null;
}
}
public boolean update() {
if (!pin.isError) {
chanLoader.loadMoreIfTime();
return true;
} else {
return false;
}
}
public void onViewed() {
if (pin.watchNewCount >= 0) {
pin.watchLastCount = pin.watchNewCount;
}
wereNewPosts = false;
if (pin.quoteNewCount >= 0) {
pin.quoteLastCount = pin.quoteNewCount;
}
wereNewQuotes = false;
}
public List<Post> getUnviewedPosts() {
if (posts.size() == 0) {
return posts;
} else {
return posts.subList(Math.max(0, posts.size() - pin.getNewPostCount()), posts.size());
}
}
public List<Post> getUnviewedQuotes() {
return quotes.subList(Math.max(0, quotes.size() - pin.getNewQuoteCount()), quotes.size());
}
public boolean getWereNewQuotes() {
if (wereNewQuotes) {
wereNewQuotes = false;
return true;
} else {
return false;
}
}
public boolean getWereNewPosts() {
if (wereNewPosts) {
wereNewPosts = false;
return true;
} else {
return false;
}
}
public long getTimeUntilNextLoad() {
return chanLoader.getTimeUntilLoadMore();
}
public boolean isLoading() {
return chanLoader.isLoading();
}
@Override
public void onChanLoaderError(VolleyError error) {
Logger.e(TAG, "PinWatcher onError");
pin.isError = true;
pin.watching = false;
Chan.getWatchManager().pinWatcherUpdated(pin);
}
@Override
public void onChanLoaderData(ChanThread thread) {
pin.isError = false;
if (pin.thumbnailUrl == null && thread.op != null && thread.op.hasImage) {
pin.thumbnailUrl = thread.op.thumbnailUrl;
}
// Populate posts list
posts.clear();
posts.addAll(thread.posts);
// Populate quotes list
quotes.clear();
// Get list of saved replies from this thread
List<Post> savedReplies = new ArrayList<>();
for (Post item : thread.posts) {
// saved.title = pin.loadable.title;
if (item.isSavedReply) {
savedReplies.add(item);
}
}
// Now get a list of posts that have a quote to a saved reply
for (Post post : thread.posts) {
for (Post saved : savedReplies) {
if (post.repliesTo.contains(saved.no)) {
quotes.add(post);
}
}
}
boolean isFirstLoad = pin.watchNewCount < 0 || pin.quoteNewCount < 0;
// If it was more than before processing
int lastWatchNewCount = pin.watchNewCount;
int lastQuoteNewCount = pin.quoteNewCount;
if (isFirstLoad) {
pin.watchLastCount = posts.size();
pin.quoteLastCount = quotes.size();
}
pin.watchNewCount = posts.size();
pin.quoteNewCount = quotes.size();
if (!isFirstLoad) {
// There were new posts after processing
if (pin.watchNewCount > lastWatchNewCount) {
wereNewPosts = true;
}
// There were new quotes after processing
if (pin.quoteNewCount > lastQuoteNewCount) {
wereNewQuotes = true;
}
}
if (Logger.debugEnabled()) {
Logger.d(TAG, String.format("postlast=%d postnew=%d werenewposts=%b quotelast=%d quotenew=%d werenewquotes=%b",
pin.watchLastCount, pin.watchNewCount, wereNewPosts, pin.quoteLastCount, pin.quoteNewCount, wereNewQuotes));
}
if (thread.archived || thread.closed) {
pin.archived = true;
pin.watching = false;
}
Chan.getWatchManager().pinWatcherUpdated(pin);
}
}

@ -90,7 +90,7 @@ public class DrawerController extends Controller implements PinAdapter.Callback,
pinAdapter = new PinAdapter(this);
recyclerView.setAdapter(pinAdapter);
pinAdapter.onPinsChanged(watchManager.getPins());
pinAdapter.onPinsChanged(watchManager.getAllPins());
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(pinAdapter.getItemTouchHelperCallback());
itemTouchHelper.attachToRecyclerView(recyclerView);
@ -157,14 +157,14 @@ public class DrawerController extends Controller implements PinAdapter.Callback,
@Override
public void onPinRemoved(final Pin pin) {
watchManager.removePin(pin);
watchManager.deletePin(pin);
Snackbar snackbar = Snackbar.make(drawerLayout, context.getString(R.string.drawer_pin_removed, pin.loadable.title), Snackbar.LENGTH_LONG);
fixSnackbarText(context, snackbar);
snackbar.setAction(R.string.undo, new View.OnClickListener() {
@Override
public void onClick(View v) {
watchManager.addPin(pin);
watchManager.createPin(pin);
}
});
snackbar.show();

@ -56,8 +56,8 @@ import static org.floens.chan.utils.AndroidUtils.getString;
public class MainSettingsController extends SettingsController implements ToolbarMenuItem.ToolbarMenuItemCallback, WatchSettingsController.WatchSettingControllerListener, PassSettingsController.PassSettingControllerListener {
private static final int ADVANCED_SETTINGS = 1;
private ListSettingView imageAutoLoadView;
private ListSettingView videoAutoLoadView;
private ListSettingView<String> imageAutoLoadView;
private ListSettingView<String> videoAutoLoadView;
private LinkSettingView watchLink;
private LinkSettingView passLink;
@ -182,7 +182,7 @@ public class MainSettingsController extends SettingsController implements Toolba
fontSizes.add(new ListSettingView.Item(name, String.valueOf(size)));
}
fontView = appearance.add(new ListSettingView(this, ChanSettings.fontSize, R.string.setting_font_size, fontSizes.toArray(new ListSettingView.Item[fontSizes.size()])));
fontView = appearance.add(new ListSettingView<>(this, ChanSettings.fontSize, R.string.setting_font_size, fontSizes.toArray(new ListSettingView.Item[fontSizes.size()])));
fontCondensed = appearance.add(new BooleanSettingView(this, ChanSettings.fontCondensed, R.string.setting_font_condensed, R.string.setting_font_condensed_description));
groups.add(appearance);
@ -215,13 +215,13 @@ public class MainSettingsController extends SettingsController implements Toolba
break;
}
imageAutoLoadTypes.add(new ListSettingView.Item(getString(name), mode.name));
videoAutoLoadTypes.add(new ListSettingView.Item(getString(name), mode.name));
imageAutoLoadTypes.add(new ListSettingView.Item<>(getString(name), mode.name));
videoAutoLoadTypes.add(new ListSettingView.Item<>(getString(name), mode.name));
}
imageAutoLoadView = new ListSettingView(this, ChanSettings.imageAutoLoadNetwork, R.string.setting_image_auto_load, imageAutoLoadTypes);
imageAutoLoadView = new ListSettingView<>(this, ChanSettings.imageAutoLoadNetwork, R.string.setting_image_auto_load, imageAutoLoadTypes);
browsing.add(imageAutoLoadView);
videoAutoLoadView = new ListSettingView(this, ChanSettings.videoAutoLoadNetwork, R.string.setting_video_auto_load, videoAutoLoadTypes);
videoAutoLoadView = new ListSettingView<>(this, ChanSettings.videoAutoLoadNetwork, R.string.setting_video_auto_load, videoAutoLoadTypes);
browsing.add(videoAutoLoadView);
updateVideoLoadModes();

@ -39,6 +39,7 @@ public class WatchSettingsController extends SettingsController implements Compo
private SettingView backgroundTimeout;
private SettingView notifyMode;
private SettingView soundMode;
private SettingView peekMode;
private SettingView ledMode;
public WatchSettingsController(Context context) {
@ -72,6 +73,7 @@ public class WatchSettingsController extends SettingsController implements Compo
setSettingViewVisibility(backgroundTimeout, false, false);
setSettingViewVisibility(notifyMode, false, false);
setSettingViewVisibility(soundMode, false, false);
setSettingViewVisibility(peekMode, false, false);
setSettingViewVisibility(ledMode, false, false);
}
}
@ -94,6 +96,7 @@ public class WatchSettingsController extends SettingsController implements Compo
setSettingViewVisibility(backgroundTimeout, enabled, true);
setSettingViewVisibility(notifyMode, enabled, true);
setSettingViewVisibility(soundMode, enabled, true);
setSettingViewVisibility(peekMode, enabled, true);
setSettingViewVisibility(ledMode, enabled, true);
}
}
@ -104,26 +107,36 @@ public class WatchSettingsController extends SettingsController implements Compo
// settings.add(new BooleanSettingView(this, ChanSettings.watchCountdown, string(R.string.setting_watch_countdown), string(R.string.setting_watch_countdown_description)));
enableBackground = settings.add(new BooleanSettingView(this, ChanSettings.watchBackground, R.string.setting_watch_enable_background, R.string.setting_watch_enable_background_description));
int[] timeouts = new int[]{1, 2, 3, 5, 10, 30, 60};
int[] timeouts = new int[]{
10 * 60 * 1000,
15 * 60 * 1000,
30 * 60 * 1000,
45 * 60 * 1000,
60 * 60 * 1000,
2 * 60 * 60 * 1000
};
ListSettingView.Item[] timeoutsItems = new ListSettingView.Item[timeouts.length];
for (int i = 0; i < timeouts.length; i++) {
String name = context.getResources().getQuantityString(R.plurals.minutes, timeouts[i], timeouts[i]);
timeoutsItems[i] = new ListSettingView.Item(name, String.valueOf(timeouts[i] * 60));
int value = timeouts[i] / 1000 / 60;
String name = context.getResources().getQuantityString(R.plurals.minutes, value, value);
timeoutsItems[i] = new ListSettingView.Item<>(name, timeouts[i]);
}
backgroundTimeout = settings.add(new ListSettingView(this, ChanSettings.watchBackgroundTimeout, R.string.setting_watch_background_timeout, timeoutsItems) {
backgroundTimeout = settings.add(new ListSettingView<Integer>(this, ChanSettings.watchBackgroundInterval, R.string.setting_watch_background_timeout, timeoutsItems) {
@Override
public String getBottomDescription() {
return getString(R.string.setting_watch_background_timeout_description) + "\n\n" + items.get(selected).name;
}
});
notifyMode = settings.add(new ListSettingView(this, ChanSettings.watchNotifyMode, R.string.setting_watch_notify_mode,
notifyMode = settings.add(new ListSettingView<>(this, ChanSettings.watchNotifyMode, R.string.setting_watch_notify_mode,
context.getResources().getStringArray(R.array.setting_watch_notify_modes), new String[]{"all", "quotes"}));
soundMode = settings.add(new ListSettingView(this, ChanSettings.watchSound, R.string.setting_watch_sound,
soundMode = settings.add(new ListSettingView<>(this, ChanSettings.watchSound, R.string.setting_watch_sound,
context.getResources().getStringArray(R.array.setting_watch_sounds), new String[]{"all", "quotes"}));
ledMode = settings.add(new ListSettingView(this, ChanSettings.watchLed, R.string.setting_watch_led,
peekMode = settings.add(new BooleanSettingView(this, ChanSettings.watchPeek, R.string.setting_watch_peek, R.string.setting_watch_peek_description));
ledMode = settings.add(new ListSettingView<>(this, ChanSettings.watchLed, R.string.setting_watch_led,
context.getResources().getStringArray(R.array.setting_watch_leds),
new String[]{"-1", "ffffffff", "ffff0000", "ffffff00", "ff00ff00", "ff00ffff", "ff0000ff", "ffff00ff"}));

@ -25,6 +25,7 @@ import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.text.TextUtils;
import org.floens.chan.Chan;
import org.floens.chan.R;
@ -32,19 +33,21 @@ import org.floens.chan.core.manager.WatchManager;
import org.floens.chan.core.model.Pin;
import org.floens.chan.core.model.Post;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.core.watch.PinWatcher;
import org.floens.chan.ui.activity.BoardActivity;
import org.floens.chan.utils.AndroidUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Pattern;
public class WatchNotifier extends Service {
private static final String TAG = "WatchNotifier";
private static final int NOTIFICATION_ID = 1;
private static final PostAgeComparer POST_AGE_COMPARER = new PostAgeComparer();
private static final PostAgeComparator POST_AGE_COMPARATOR = new PostAgeComparator();
private static final int SUBJECT_LENGTH = 6;
private static final String IMAGE_TEXT = "(img) ";
private static final Pattern SHORTEN_NO_PATTERN = Pattern.compile(">>\\d+(?=\\d{3})(\\d{3})");
private NotificationManager nm;
private WatchManager wm;
@ -86,49 +89,54 @@ public class WatchNotifier extends Service {
}
public void pausePins() {
wm.pausePins();
wm.pauseAll();
}
private Notification createNotification() {
boolean notifyQuotesOnly = ChanSettings.watchNotifyMode.get().equals("quotes");
boolean soundQuotesOnly = ChanSettings.watchSound.get().equals("quotes");
List<Post> list = new ArrayList<>();
List<Post> unviewedPosts = new ArrayList<>();
List<Post> listQuoting = new ArrayList<>();
List<Pin> pins = new ArrayList<>();
List<Pin> subjectPins = new ArrayList<>();
boolean ticker = false;
boolean light = false;
boolean sound = false;
boolean peek = false;
for (Pin pin : wm.getWatchingPins()) {
PinWatcher watcher = pin.getPinWatcher();
if (watcher == null || pin.isError)
WatchManager.PinWatcher watcher = wm.getPinWatcher(pin);
if (watcher == null || pin.isError) {
continue;
}
pins.add(pin);
if (notifyQuotesOnly) {
list.addAll(watcher.getUnviewedQuotes());
unviewedPosts.addAll(watcher.getUnviewedQuotes());
listQuoting.addAll(watcher.getUnviewedQuotes());
if (watcher.getWereNewQuotes()) {
ticker = true;
light = true;
sound = true;
peek = true;
}
if (pin.getNewQuoteCount() > 0) {
subjectPins.add(pin);
}
} else {
list.addAll(watcher.getUnviewedPosts());
unviewedPosts.addAll(watcher.getUnviewedPosts());
listQuoting.addAll(watcher.getUnviewedQuotes());
if (watcher.getWereNewPosts()) {
ticker = true;
light = true;
if (!soundQuotesOnly) {
sound = true;
peek = true;
}
}
if (watcher.getWereNewQuotes()) {
sound = true;
peek = true;
}
if (pin.getNewPostCount() > 0) {
subjectPins.add(pin);
@ -137,63 +145,69 @@ public class WatchNotifier extends Service {
}
if (Chan.getInstance().getApplicationInForeground()) {
ticker = false;
light = false;
sound = false;
}
return notifyAboutPosts(pins, subjectPins, list, listQuoting, notifyQuotesOnly, ticker, sound);
if (!ChanSettings.watchPeek.get()) {
peek = false;
}
return notifyAboutPosts(pins, subjectPins, unviewedPosts, listQuoting, notifyQuotesOnly, light, sound, peek);
}
private Notification notifyAboutPosts(List<Pin> pins, List<Pin> subjectPins, List<Post> list, List<Post> listQuoting,
boolean notifyQuotesOnly, boolean makeTicker, boolean makeSound) {
private Notification notifyAboutPosts(List<Pin> pins, List<Pin> subjectPins, List<Post> unviewedPosts, List<Post> listQuoting,
boolean notifyQuotesOnly, boolean light, boolean sound, boolean peek) {
String title = getResources().getQuantityString(R.plurals.watch_title, pins.size(), pins.size());
if (list.size() == 0) {
if (unviewedPosts.isEmpty()) {
// Idle notification
String message = getString(R.string.watch_idle);
return getNotificationFor(null, title, message, -1, null, false, false, false, null);
return get(title, message, null, false, false, false, false, null);
} else {
// New posts notification
String message;
List<Post> notificationList;
List<Post> postsForExpandedLines;
if (notifyQuotesOnly) {
message = getResources().getQuantityString(R.plurals.watch_new_quotes, listQuoting.size(), listQuoting.size());
notificationList = listQuoting;
postsForExpandedLines = listQuoting;
} else {
notificationList = list;
postsForExpandedLines = unviewedPosts;
if (listQuoting.size() > 0) {
message = getResources().getQuantityString(R.plurals.watch_new_quoting, list.size(), list.size(), listQuoting.size());
message = getResources().getQuantityString(R.plurals.watch_new_quoting, unviewedPosts.size(), unviewedPosts.size(), listQuoting.size());
} else {
message = getResources().getQuantityString(R.plurals.watch_new, list.size(), list.size());
message = getResources().getQuantityString(R.plurals.watch_new, unviewedPosts.size(), unviewedPosts.size());
}
}
Collections.sort(notificationList, POST_AGE_COMPARER);
List<CharSequence> lines = new ArrayList<>();
for (Post post : notificationList) {
CharSequence prefix = AndroidUtils.ellipsize(post.title, 18);
CharSequence comment;
if (post.comment.length() == 0) {
comment = "(image)";
Collections.sort(postsForExpandedLines, POST_AGE_COMPARATOR);
List<CharSequence> expandedLines = new ArrayList<>();
for (Post postForExpandedLine : postsForExpandedLines) {
CharSequence prefix;
if (postForExpandedLine.title.length() <= SUBJECT_LENGTH) {
prefix = postForExpandedLine.title;
} else {
comment = post.comment;
prefix = postForExpandedLine.title.subSequence(0, SUBJECT_LENGTH);
}
lines.add(prefix + ": " + comment);
String comment = postForExpandedLine.hasImage ? IMAGE_TEXT : "";
if (postForExpandedLine.comment.length() > 0) {
comment += postForExpandedLine.comment;
}
Pin subject = null;
if (subjectPins.size() == 1) {
subject = subjectPins.get(0);
// Replace >>132456798 with >789 to shorten the notification
comment = SHORTEN_NO_PATTERN.matcher(comment).replaceAll(">$1");
expandedLines.add(prefix + ": " + comment);
}
String ticker = null;
if (makeTicker) {
ticker = message;
Pin targetPin = null;
if (subjectPins.size() == 1) {
targetPin = subjectPins.get(0);
}
return getNotificationFor(ticker, title, message, -1, lines, makeTicker, makeSound, true, subject);
String smallText = TextUtils.join(", ", expandedLines);
return get(message, smallText, expandedLines, light, sound, peek, true, targetPin);
}
}
@ -201,17 +215,16 @@ public class WatchNotifier extends Service {
* Create a notification with the supplied parameters.
* The style of the big notification is InboxStyle, a list of text.
*
* @param tickerText The tickertext to show, or null if no tickertext should be shown.
* @param contentTitle The title of the notification
* @param contentText The content of the small notification
* @param contentNumber The contentInfo and number, or -1 if not shown
* @param title The title of the notification
* @param smallText The content of the small notification
* @param expandedLines A list of lines for the big notification, or null if not shown
* @param makeSound Should the notification make a sound
* @param sound Should the notification make a sound
* @param peek Peek the notification into the screen
* @param alertIcon Show the alert version of the icon
* @param target The target pin, or null to open the pinned pane on tap
*/
@SuppressWarnings("deprecation")
private Notification getNotificationFor(String tickerText, String contentTitle, String contentText, int contentNumber,
List<CharSequence> expandedLines, boolean light, boolean makeSound, boolean alert, Pin target) {
private Notification get(String title, String smallText, List<CharSequence> expandedLines,
boolean light, boolean sound, boolean peek, boolean alertIcon, Pin target) {
Intent intent = new Intent(this, BoardActivity.class);
intent.setAction(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_LAUNCHER);
@ -223,7 +236,7 @@ public class WatchNotifier extends Service {
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
if (makeSound) {
if (sound || peek) {
builder.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE);
}
@ -234,24 +247,21 @@ public class WatchNotifier extends Service {
}
}
builder.setContentIntent(pendingIntent);
if (tickerText != null) {
tickerText = tickerText.substring(0, Math.min(tickerText.length(), 50));
builder.setContentTitle(title);
if (smallText != null) {
builder.setContentText(smallText);
}
builder.setTicker(tickerText);
builder.setContentTitle(contentTitle);
builder.setContentText(contentText);
if (contentNumber >= 0) {
builder.setContentInfo(Integer.toString(contentNumber));
builder.setNumber(contentNumber);
if (alertIcon || peek) {
builder.setSmallIcon(R.drawable.ic_stat_notify_alert);
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
} else {
builder.setSmallIcon(R.drawable.ic_stat_notify);
builder.setPriority(NotificationCompat.PRIORITY_MIN);
}
builder.setSmallIcon(alert ? R.drawable.ic_stat_notify_alert : R.drawable.ic_stat_notify);
Intent pauseWatching = new Intent(this, WatchNotifier.class);
pauseWatching.putExtra("pause_pins", true);
@ -266,14 +276,14 @@ public class WatchNotifier extends Service {
for (CharSequence line : expandedLines.subList(Math.max(0, expandedLines.size() - 10), expandedLines.size())) {
style.addLine(line);
}
style.setBigContentTitle(contentTitle);
style.setBigContentTitle(title);
builder.setStyle(style);
}
return builder.getNotification();
return builder.build();
}
private static class PostAgeComparer implements Comparator<Post> {
private static class PostAgeComparator implements Comparator<Post> {
@Override
public int compare(Post lhs, Post rhs) {
if (lhs.time < rhs.time) {

@ -31,43 +31,43 @@ import java.util.List;
import static org.floens.chan.utils.AndroidUtils.dp;
public class ListSettingView extends SettingView implements FloatingMenu.FloatingMenuCallback, View.OnClickListener {
public class ListSettingView<T> extends SettingView implements FloatingMenu.FloatingMenuCallback, View.OnClickListener {
public final List<Item> items;
public int selected;
private Setting<String> setting;
private Setting<T> setting;
public ListSettingView(SettingsController settingsController, Setting<String> setting, int name, String[] itemNames, String[] keys) {
public ListSettingView(SettingsController settingsController, Setting<T> setting, int name, String[] itemNames, String[] keys) {
this(settingsController, setting, getString(name), itemNames, keys);
}
public ListSettingView(SettingsController settingsController, Setting<String> setting, String name, String[] itemNames, String[] keys) {
public ListSettingView(SettingsController settingsController, Setting<T> setting, String name, String[] itemNames, String[] keys) {
super(settingsController, name);
this.setting = setting;
items = new ArrayList<>(itemNames.length);
for (int i = 0; i < itemNames.length; i++) {
items.add(i, new Item(itemNames[i], keys[i]));
items.add(i, new Item<>(itemNames[i], keys[i]));
}
updateSelection();
}
public ListSettingView(SettingsController settingsController, Setting<String> setting, int name, Item[] items) {
public ListSettingView(SettingsController settingsController, Setting<T> setting, int name, Item[] items) {
this(settingsController, setting, getString(name), items);
}
public ListSettingView(SettingsController settingsController, Setting<String> setting, int name, List<Item> items) {
public ListSettingView(SettingsController settingsController, Setting<T> setting, int name, List<Item> items) {
this(settingsController, setting, getString(name), items);
}
public ListSettingView(SettingsController settingsController, Setting<String> setting, String name, Item[] items) {
public ListSettingView(SettingsController settingsController, Setting<T> setting, String name, Item[] items) {
this(settingsController, setting, name, Arrays.asList(items));
}
public ListSettingView(SettingsController settingsController, Setting<String> setting, String name, List<Item> items) {
public ListSettingView(SettingsController settingsController, Setting<T> setting, String name, List<Item> items) {
super(settingsController, name);
this.setting = setting;
this.items = items;
@ -79,7 +79,7 @@ public class ListSettingView extends SettingView implements FloatingMenu.Floatin
return items.get(selected).name;
}
public Setting<String> getSetting() {
public Setting<T> getSetting() {
return setting;
}
@ -114,9 +114,10 @@ public class ListSettingView extends SettingView implements FloatingMenu.Floatin
menu.show();
}
@SuppressWarnings("unchecked")
@Override
public void onFloatingMenuItemClicked(FloatingMenu menu, FloatingMenuItem item) {
String selectedKey = (String) item.getId();
T selectedKey = (T) item.getId();
setting.set(selectedKey);
updateSelection();
settingsController.onPreferenceChange(this);
@ -127,7 +128,7 @@ public class ListSettingView extends SettingView implements FloatingMenu.Floatin
}
public void updateSelection() {
String selectedKey = setting.get();
T selectedKey = setting.get();
for (int i = 0; i < items.size(); i++) {
if (items.get(i).key.equals(selectedKey)) {
selected = i;
@ -136,18 +137,18 @@ public class ListSettingView extends SettingView implements FloatingMenu.Floatin
}
}
public static class Item {
public static class Item<T> {
public final String name;
public final String key;
public final T key;
public boolean enabled;
public Item(String name, String key) {
public Item(String name, T key) {
this.name = name;
this.key = key;
enabled = true;
}
public Item(String name, String key, boolean enabled) {
public Item(String name, T key, boolean enabled) {
this.name = name;
this.key = key;
this.enabled = enabled;

@ -400,18 +400,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<string name="setting_watch_summary_disabled">Off</string>
<string name="setting_watch_enable_background">Enable in the background</string>
<string name="setting_watch_enable_background_description">Watch pins when Clover is in the background</string>
<string name="setting_watch_background_timeout">Minimum time between loads in background</string>
<string name="setting_watch_background_timeout_description">Minimum time between loads, with exponential backoff</string>
<string name="setting_watch_background_timeout">Background update interval</string>
<string name="setting_watch_background_timeout_description">The time between updates in the background</string>
<string name="setting_watch_notify_mode">Notify about</string>
<string-array name="setting_watch_notify_modes">
<item>All posts</item>
<item>Only posts quoting you</item>
</string-array>
<string name="setting_watch_sound">Notification sound and vibrate</string>
<string name="setting_watch_sound">Notification sound</string>
<string-array name="setting_watch_sounds">
<item>All posts</item>
<item>Only posts quoting you</item>
</string-array>
<string name="setting_watch_peek">Heads-up notification on mentions</string>
<string name="setting_watch_peek_description">Show a heads-up notification on mentions</string>
<string name="setting_watch_led">Notification light</string>
<string-array name="setting_watch_leds">
<item>None</item>

Loading…
Cancel
Save