diff --git a/Clover/app/src/main/java/org/floens/chan/core/manager/WatchManager.java b/Clover/app/src/main/java/org/floens/chan/core/manager/WatchManager.java index 9ee4a05b..d3104f13 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/manager/WatchManager.java +++ b/Clover/app/src/main/java/org/floens/chan/core/manager/WatchManager.java @@ -21,11 +21,16 @@ import android.app.AlarmManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.graphics.Bitmap; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.os.PowerManager; import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.volley.VolleyError; +import com.android.volley.toolbox.ImageLoader; import org.floens.chan.Chan; import org.floens.chan.core.database.DatabaseManager; @@ -40,7 +45,7 @@ import org.floens.chan.core.pool.ChanLoaderFactory; import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.core.site.loader.ChanThreadLoader; import org.floens.chan.ui.helper.PostHelper; -import org.floens.chan.ui.service.WatchNotifier; +import org.floens.chan.ui.notification.ThreadWatchNotifications; import org.floens.chan.utils.Logger; import java.util.ArrayList; @@ -59,6 +64,7 @@ import javax.inject.Singleton; import de.greenrobot.event.EventBus; import static org.floens.chan.Chan.inject; +import static org.floens.chan.Chan.injector; import static org.floens.chan.utils.AndroidUtils.getAppContext; /** @@ -103,7 +109,7 @@ public class WatchManager { 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 String WAKELOCK_TAG = "org.floens.chan:watch_manager_update_lock"; private static final long WAKELOCK_MAX_TIME = 60 * 1000; private static final long BACKGROUND_UPDATE_MIN_DELAY = 90 * 1000; @@ -133,13 +139,18 @@ public class WatchManager { private PowerManager.WakeLock wakeLock; private long lastBackgroundUpdateTime; + private ThreadWatchNotifications threadWatchNotifications; + @Inject - public WatchManager(DatabaseManager databaseManager, ChanLoaderFactory chanLoaderFactory) { - alarmManager = (AlarmManager) getAppContext().getSystemService(Context.ALARM_SERVICE); - powerManager = (PowerManager) getAppContext().getSystemService(Context.POWER_SERVICE); + public WatchManager(Context applicationContext, + DatabaseManager databaseManager, ChanLoaderFactory chanLoaderFactory, + ThreadWatchNotifications threadWatchNotifications) { + alarmManager = (AlarmManager) applicationContext.getSystemService(Context.ALARM_SERVICE); + powerManager = (PowerManager) applicationContext.getSystemService(Context.POWER_SERVICE); this.databaseManager = databaseManager; this.chanLoaderFactory = chanLoaderFactory; + this.threadWatchNotifications = threadWatchNotifications; databasePinManager = databaseManager.getDatabasePinManager(); pins = databaseManager.runTask(databasePinManager.getPins()); @@ -543,10 +554,18 @@ public class WatchManager { // Update notification state if (watchEnabled && backgroundEnabled) { - // Also calls onStartCommand, which updates the notification with new info - getAppContext().startService(new Intent(getAppContext(), WatchNotifier.class)); + // Show/update notification + List pinWatchers = new ArrayList<>(); + for (Pin watchingPin : getWatchingPins()) { + PinWatcher pinWatcher = getPinWatcher(watchingPin); + if (pinWatcher != null && !watchingPin.isError) { + pinWatchers.add(pinWatcher); + } + } + + threadWatchNotifications.showForWatchers(pinWatchers); } else { - getAppContext().stopService(new Intent(getAppContext(), WatchNotifier.class)); + threadWatchNotifications.hide(); } } @@ -668,9 +687,12 @@ public class WatchManager { } } - public class PinWatcher implements ChanThreadLoader.ChanLoaderCallback { + public class PinWatcher implements ChanThreadLoader.ChanLoaderCallback, ImageLoader.ImageListener { private static final String TAG = "PinWatcher"; + // Width and height of the bitmap for the notification image. + private static final int THUMBNAIL_SIZE = 128; + private final Pin pin; private ChanThreadLoader chanLoader; @@ -679,6 +701,11 @@ public class WatchManager { private boolean wereNewQuotes = false; private boolean wereNewPosts = false; + private boolean requireNotificationUpdate = true; + + private Bitmap thumbnailBitmap = null; + private ImageLoader.ImageContainer thumbnailContainer; + public PinWatcher(Pin pin) { this.pin = pin; inject(this); @@ -687,6 +714,27 @@ public class WatchManager { chanLoader = chanLoaderFactory.obtain(pin.loadable, this); } + public int getPinId() { + return pin.id; + } + + public String getTitle() { + return pin.loadable.title; + } + + public boolean requiresNotificationUpdate() { + return requireNotificationUpdate; + } + + public void hadNotificationUpdate() { + requireNotificationUpdate = false; + } + + @Nullable + public Bitmap getThumbnailBitmap() { + return thumbnailBitmap; + } + public List getUnviewedPosts() { if (posts.size() == 0) { return posts; @@ -728,10 +776,13 @@ public class WatchManager { private void onViewed() { wereNewPosts = false; wereNewQuotes = false; + requireNotificationUpdate = true; } private boolean update(boolean fromBackground) { if (!pin.isError && pin.watching) { + loadThumbnailBitmapIfNeeded(); + if (fromBackground) { // Always load regardless of timer, since the time left is not accurate for 15min+ intervals chanLoader.clearTimer(); @@ -774,20 +825,20 @@ public class WatchManager { quotes.clear(); // Get list of saved replies from this thread - List savedReplies = new ArrayList<>(); + Set savedReplies = new HashSet<>(); for (Post item : thread.posts) { - // saved.title = pin.loadable.title; - if (item.isSavedReply) { - savedReplies.add(item); + savedReplies.add(item.no); } } // Now get a list of posts that have a quote to a saved reply + out: for (Post post : thread.posts) { - for (Post saved : savedReplies) { - if (post.repliesTo.contains(saved.no)) { + for (Integer no : post.repliesTo) { + if (savedReplies.contains(no)) { quotes.add(post); + continue out; } } } @@ -801,6 +852,7 @@ public class WatchManager { if (isFirstLoad) { pin.watchLastCount = posts.size(); pin.quoteLastCount = quotes.size(); + requireNotificationUpdate = true; } pin.watchNewCount = posts.size(); @@ -810,11 +862,13 @@ public class WatchManager { // There were new posts after processing if (pin.watchNewCount > lastWatchNewCount) { wereNewPosts = true; + requireNotificationUpdate = true; } // There were new quotes after processing if (pin.quoteNewCount > lastQuoteNewCount) { wereNewQuotes = true; + requireNotificationUpdate = true; } } @@ -828,9 +882,33 @@ public class WatchManager { if (thread.archived || thread.closed) { pin.archived = true; pin.watching = false; + requireNotificationUpdate = true; } pinWatcherUpdated(this); + + loadThumbnailBitmapIfNeeded(); + } + + private void loadThumbnailBitmapIfNeeded() { + if (TextUtils.isEmpty(pin.thumbnailUrl) || thumbnailContainer != null) { + return; + } + + thumbnailContainer = injector().instance(ImageLoader.class) + .get(pin.thumbnailUrl, this, + THUMBNAIL_SIZE, THUMBNAIL_SIZE); + } + + @Override + public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate) { + if (response.getBitmap() != null) { + thumbnailBitmap = response.getBitmap(); + } + } + + @Override + public void onErrorResponse(VolleyError error) { } } } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/notification/NotificationHelper.java b/Clover/app/src/main/java/org/floens/chan/ui/notification/NotificationHelper.java new file mode 100644 index 00000000..3d2c198d --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/ui/notification/NotificationHelper.java @@ -0,0 +1,16 @@ +package org.floens.chan.ui.notification; + +import android.app.NotificationManager; +import android.content.Context; + +public class NotificationHelper { + protected final Context applicationContext; + protected final NotificationManager notificationManager; + + public NotificationHelper(Context applicationContext) { + this.applicationContext = applicationContext; + + notificationManager = (NotificationManager) applicationContext. + getSystemService(Context.NOTIFICATION_SERVICE); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/notification/ThreadWatchNotifications.java b/Clover/app/src/main/java/org/floens/chan/ui/notification/ThreadWatchNotifications.java new file mode 100644 index 00000000..c4f12cfb --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/ui/notification/ThreadWatchNotifications.java @@ -0,0 +1,180 @@ +package org.floens.chan.ui.notification; + +import android.annotation.TargetApi; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.support.v4.app.NotificationCompat; + +import org.floens.chan.R; +import org.floens.chan.core.manager.WatchManager; +import org.floens.chan.core.model.Post; +import org.floens.chan.ui.activity.BoardActivity; + +import java.util.List; +import java.util.regex.Pattern; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ThreadWatchNotifications extends NotificationHelper { + private static final String CHANNEL_ID_WATCH_NORMAL = "watch:normal"; + private static final String CHANNEL_ID_WATCH_MENTION = "watch:mention"; + private static final int NOTIFICATION_ID_WATCH_NORMAL = 0x10000; + private static final int NOTIFICATION_ID_WATCH_NORMAL_MASK = 0xffff; + private static final int NOTIFICATION_ID_WATCH_MENTION = 0x20000; + private static final int NOTIFICATION_ID_WATCH_MENTION_MASK = 0xffff; + + private static final String POST_COMMENT_IMAGE_PREFIX = "(img) "; + private static final Pattern POST_COMMENT_SHORTEN_NO_PATTERN = + Pattern.compile(">>\\d+(?=\\d{4})(\\d{4})"); + + @Inject + public ThreadWatchNotifications(Context applicationContext) { + super(applicationContext); + } + + public void showForWatchers(List pinWatchers) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + showPinSummaries(pinWatchers); + } else { + // legacy notification + } + } + + public void hide() { + + } + + @TargetApi(Build.VERSION_CODES.O) + private void showPinSummaries(List pinWatchers) { + ensureChannels(); + + for (WatchManager.PinWatcher pinWatcher : pinWatchers) { + if (!pinWatcher.requiresNotificationUpdate()) { + continue; + } + + // Normal thread posts. + if (!pinWatcher.getUnviewedPosts().isEmpty()) { + NotificationCompat.Builder builder = + new NotificationCompat.Builder(applicationContext, + CHANNEL_ID_WATCH_NORMAL); + + NotificationCompat.MessagingStyle messagingStyle = + new NotificationCompat.MessagingStyle(""); + + builder.setSmallIcon(R.drawable.ic_stat_notify); + if (pinWatcher.getThumbnailBitmap() != null) { + builder.setLargeIcon(pinWatcher.getThumbnailBitmap()); + } + + String subTitle = "(" + pinWatcher.getUnviewedPosts().size() + ")"; + messagingStyle.setConversationTitle(pinWatcher.getTitle() + " " + subTitle); + messagingStyle.setGroupConversation(true); + addPostsToMessagingStyle(messagingStyle, pinWatcher.getUnviewedPosts()); + builder.setStyle(messagingStyle); + + setNotificationIntent(builder); + + int id = NOTIFICATION_ID_WATCH_NORMAL + + (pinWatcher.getPinId() & NOTIFICATION_ID_WATCH_NORMAL_MASK); + notificationManager.notify(id, builder.build()); + } + + // Posts that mention you. + if (!pinWatcher.getUnviewedQuotes().isEmpty()) { + NotificationCompat.Builder builder = + new NotificationCompat.Builder(applicationContext, + CHANNEL_ID_WATCH_MENTION); + + NotificationCompat.MessagingStyle messagingStyle = + new NotificationCompat.MessagingStyle(""); + + builder.setSmallIcon(R.drawable.ic_stat_notify_alert); + builder.setSubText("Mentions"); + if (pinWatcher.getThumbnailBitmap() != null) { + builder.setLargeIcon(pinWatcher.getThumbnailBitmap()); + } + + String subTitle = "(" + pinWatcher.getUnviewedQuotes().size() + " mentions)"; + messagingStyle.setConversationTitle(pinWatcher.getTitle() + " " + subTitle); + messagingStyle.setGroupConversation(true); + addPostsToMessagingStyle(messagingStyle, pinWatcher.getUnviewedQuotes()); + builder.setStyle(messagingStyle); + + setNotificationIntent(builder); + + int id = NOTIFICATION_ID_WATCH_MENTION + + (pinWatcher.getPinId() & NOTIFICATION_ID_WATCH_MENTION_MASK); + notificationManager.notify(id, builder.build()); + } + + pinWatcher.hadNotificationUpdate(); + } + } + + private void addPostsToMessagingStyle(NotificationCompat.MessagingStyle messagingStyle, + List unviewedPosts) { + final int maxLines = 25; + + if (unviewedPosts.size() > maxLines) { + unviewedPosts = unviewedPosts.subList( + unviewedPosts.size() - maxLines, unviewedPosts.size()); + } + + for (Post post : unviewedPosts) { + String comment = post.image() != null ? POST_COMMENT_IMAGE_PREFIX : ""; + if (post.comment.length() > 0) { + comment += post.comment; + } + + // Replace >>132456798 with >6789 to shorten the notification + comment = POST_COMMENT_SHORTEN_NO_PATTERN.matcher(comment) + .replaceAll(">$1"); + + CharSequence name = post.nameTripcodeIdCapcodeSpan; +// if (name.length() == 0) { +// name = "Anonymous"; +// } + messagingStyle.addMessage(comment, post.time, name); + } + } + + @TargetApi(Build.VERSION_CODES.O) + private void setNotificationIntent(NotificationCompat.Builder builder) { + Intent intent = new Intent(applicationContext, BoardActivity.class); + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | + Intent.FLAG_ACTIVITY_SINGLE_TOP | + Intent.FLAG_ACTIVITY_NEW_TASK | + Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + + PendingIntent pendingIntent = PendingIntent.getActivity( + applicationContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + builder.setContentIntent(pendingIntent); + } + + @TargetApi(Build.VERSION_CODES.O) + private void ensureChannels() { + NotificationChannel normalChannel = new NotificationChannel( + CHANNEL_ID_WATCH_NORMAL, "Thread updates", + NotificationManager.IMPORTANCE_DEFAULT); + normalChannel.setDescription("Normal posts for threads"); + notificationManager.createNotificationChannel(normalChannel); + + NotificationChannel mentionChannel = new NotificationChannel( + CHANNEL_ID_WATCH_MENTION, "Thread mentions", + NotificationManager.IMPORTANCE_HIGH); + mentionChannel.setDescription("Posts were you were mentioned"); + mentionChannel.enableVibration(true); + mentionChannel.enableLights(true); + notificationManager.createNotificationChannel(mentionChannel); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/service/WatchNotifier.java b/Clover/app/src/main/java/org/floens/chan/ui/service/WatchNotifier.java index dbc3eb7c..a52e8514 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/service/WatchNotifier.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/service/WatchNotifier.java @@ -24,6 +24,7 @@ import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.IBinder; +import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.text.TextUtils; @@ -69,8 +70,6 @@ public class WatchNotifier extends Service { inject(this); nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - startForeground(NOTIFICATION_ID, createNotification()); } @Override @@ -91,13 +90,17 @@ public class WatchNotifier extends Service { } public void updateNotification() { - nm.notify(NOTIFICATION_ID, createNotification()); + Notification notification = createNotification(); + if (notification != null) { + nm.notify(NOTIFICATION_ID, notification); + } } public void pausePins() { watchManager.pauseAll(); } + @Nullable private Notification createNotification() { boolean notifyQuotesOnly = ChanSettings.watchNotifyMode.get().equals("quotes"); boolean soundQuotesOnly = ChanSettings.watchSound.get().equals("quotes"); @@ -162,59 +165,55 @@ public class WatchNotifier extends Service { return notifyAboutPosts(pins, subjectPins, unviewedPosts, listQuoting, notifyQuotesOnly, light, sound, peek); } + @Nullable private Notification notifyAboutPosts(List pins, List subjectPins, List unviewedPosts, List listQuoting, boolean notifyQuotesOnly, boolean light, boolean sound, boolean peek) { - String title = getResources().getQuantityString(R.plurals.watch_title, pins.size(), pins.size()); - if (unviewedPosts.isEmpty()) { - // Idle notification - String message = getString(R.string.watch_idle); - return get(title, message, null, false, false, false, false, null); + return null; + } + // New posts notification + String message; + List postsForExpandedLines; + if (notifyQuotesOnly) { + message = getResources().getQuantityString(R.plurals.watch_new_quotes, listQuoting.size(), listQuoting.size()); + postsForExpandedLines = listQuoting; } else { - // New posts notification - String message; - List postsForExpandedLines; - if (notifyQuotesOnly) { - message = getResources().getQuantityString(R.plurals.watch_new_quotes, listQuoting.size(), listQuoting.size()); - postsForExpandedLines = listQuoting; + postsForExpandedLines = unviewedPosts; + if (listQuoting.size() > 0) { + message = getResources().getQuantityString(R.plurals.watch_new_quoting, unviewedPosts.size(), unviewedPosts.size(), listQuoting.size()); } else { - postsForExpandedLines = unviewedPosts; - if (listQuoting.size() > 0) { - message = getResources().getQuantityString(R.plurals.watch_new_quoting, unviewedPosts.size(), unviewedPosts.size(), listQuoting.size()); - } else { - message = getResources().getQuantityString(R.plurals.watch_new, unviewedPosts.size(), unviewedPosts.size()); - } + message = getResources().getQuantityString(R.plurals.watch_new, unviewedPosts.size(), unviewedPosts.size()); } + } - Collections.sort(postsForExpandedLines, POST_AGE_COMPARATOR); - List expandedLines = new ArrayList<>(); - for (Post postForExpandedLine : postsForExpandedLines) { - CharSequence prefix; - if (postForExpandedLine.getTitle().length() <= SUBJECT_LENGTH) { - prefix = postForExpandedLine.getTitle(); - } else { - prefix = postForExpandedLine.getTitle().subSequence(0, SUBJECT_LENGTH); - } - - String comment = postForExpandedLine.image() != null ? IMAGE_TEXT : ""; - if (postForExpandedLine.comment.length() > 0) { - comment += postForExpandedLine.comment; - } - - // Replace >>132456798 with >789 to shorten the notification - comment = SHORTEN_NO_PATTERN.matcher(comment).replaceAll(">$1"); - - expandedLines.add(prefix + ": " + comment); + Collections.sort(postsForExpandedLines, POST_AGE_COMPARATOR); + List expandedLines = new ArrayList<>(); + for (Post postForExpandedLine : postsForExpandedLines) { + CharSequence prefix; + if (postForExpandedLine.getTitle().length() <= SUBJECT_LENGTH) { + prefix = postForExpandedLine.getTitle(); + } else { + prefix = postForExpandedLine.getTitle().subSequence(0, SUBJECT_LENGTH); } - Pin targetPin = null; - if (subjectPins.size() == 1) { - targetPin = subjectPins.get(0); + String comment = postForExpandedLine.image() != null ? IMAGE_TEXT : ""; + if (postForExpandedLine.comment.length() > 0) { + comment += postForExpandedLine.comment; } - String smallText = TextUtils.join(", ", expandedLines); - return get(message, smallText, expandedLines, light, sound, peek, true, targetPin); + // Replace >>132456798 with >789 to shorten the notification + comment = SHORTEN_NO_PATTERN.matcher(comment).replaceAll(">$1"); + + expandedLines.add(prefix + ": " + comment); + } + + Pin targetPin = null; + if (subjectPins.size() == 1) { + targetPin = subjectPins.get(0); } + + String smallText = TextUtils.join(", ", expandedLines); + return get(message, smallText, expandedLines, light, sound, peek, true, targetPin); } /**