From 9693adfbb47673d18de3653fef9267eaf861f94d Mon Sep 17 00:00:00 2001 From: M66B Date: Thu, 16 Nov 2023 12:06:12 +0100 Subject: [PATCH] Notifications: refactoring --- app/src/main/java/eu/faircode/email/Core.java | 1027 ---------------- .../eu/faircode/email/NotificationHelper.java | 1056 +++++++++++++++++ .../eu/faircode/email/ServiceSynchronize.java | 4 +- 3 files changed, 1058 insertions(+), 1029 deletions(-) diff --git a/app/src/main/java/eu/faircode/email/Core.java b/app/src/main/java/eu/faircode/email/Core.java index db1fb69808..f45166eff5 100644 --- a/app/src/main/java/eu/faircode/email/Core.java +++ b/app/src/main/java/eu/faircode/email/Core.java @@ -144,9 +144,6 @@ import me.leolin.shortcutbadger.ShortcutBadgerAlt; class Core { static final int DEFAULT_CHUNK_SIZE = 50; - private static final int MAX_NOTIFICATION_DISPLAY = 10; // per group - private static final int MAX_NOTIFICATION_COUNT = 100; // per group - private static final long SCREEN_ON_DURATION = 3000L; // milliseconds private static final int SYNC_BATCH_SIZE = 20; private static final int DOWNLOAD_BATCH_SIZE = 20; private static final long YIELD_DURATION = 200L; // milliseconds @@ -156,18 +153,11 @@ class Core { private static final int LOCAL_RETRY_MAX = 2; private static final long LOCAL_RETRY_DELAY = 5 * 1000L; // milliseconds private static final int TOTAL_RETRY_MAX = LOCAL_RETRY_MAX * 5; - private static final int MAX_PREVIEW = 5000; // characters private static final long EXISTS_RETRY_DELAY = 20 * 1000L; // milliseconds private static final int FIND_RETRY_COUNT = 3; // times private static final long FIND_RETRY_DELAY = 5 * 1000L; // milliseconds private static final int POP3_KEEP_EXTRA = 100; // messages - // Android applies a rate limit when updating a notification. - // If you post updates to a notification too frequently—many in less than one second— - // the system might drop updates. - private static final int DEFAULT_MAX_NOTIFICATION_ENQUEUE_RATE = 5; // NotificationManagerService.java - private static final long NOTIFY_DELAY = 1000L / DEFAULT_MAX_NOTIFICATION_ENQUEUE_RATE; // milliseconds - private static final Map> accountIdentities = new HashMap<>(); static void clearIdentities() { @@ -5518,983 +5508,6 @@ class Core { } } - static void notifyMessages(Context context, List messages, NotificationData data, boolean foreground) { - if (messages == null) - messages = new ArrayList<>(); - - NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); - if (nm == null) - return; - - DB db = DB.getInstance(context); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - boolean badge = prefs.getBoolean("badge", true); - boolean notify_background_only = prefs.getBoolean("notify_background_only", false); - boolean notify_summary = prefs.getBoolean("notify_summary", false); - boolean notify_preview = prefs.getBoolean("notify_preview", true); - boolean notify_preview_only = prefs.getBoolean("notify_preview_only", false); - boolean notify_screen_on = prefs.getBoolean("notify_screen_on", false); - boolean wearable_preview = prefs.getBoolean("wearable_preview", false); - boolean biometrics = prefs.getBoolean("biometrics", false); - String pin = prefs.getString("pin", null); - boolean biometric_notify = prefs.getBoolean("biometrics_notify", true); - boolean pro = ActivityBilling.isPro(context); - - boolean redacted = ((biometrics || !TextUtils.isEmpty(pin)) && !biometric_notify); - if (redacted) - notify_summary = true; - if (notify_screen_on && - !(BuildConfig.DEBUG || - Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU || - Helper.hasPermission(context, "android.permission.TURN_SCREEN_ON"))) - notify_screen_on = false; - - Log.i("Notify messages=" + messages.size() + - " biometrics=" + biometrics + "/" + biometric_notify + - " summary=" + notify_summary); - - Map newMessages = new HashMap<>(); - - Map> groupMessages = new HashMap<>(); - for (long group : data.groupNotifying.keySet()) - groupMessages.put(group, new ArrayList<>()); - - Map channelIdDisabled = new HashMap<>(); - - // Current - for (TupleMessageEx message : messages) { - if (message.notifying == EntityMessage.NOTIFYING_IGNORE) { - Log.e("Notify ignore"); - continue; - } - - // Check if notification channel enabled - if (message.notifying == 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pro) { - // Disabling a channel for a sender or folder doesn't disable notifications - // because the (account) summary notification isn't disabled - // So, suppress notifications here - - String mChannelId = message.getNotificationChannelId(); - if (mChannelId != null && !channelIdDisabled.containsKey(mChannelId)) { - NotificationChannel channel = nm.getNotificationChannel(mChannelId); - channelIdDisabled.put(mChannelId, - channel != null && channel.getImportance() == NotificationManager.IMPORTANCE_NONE); - } - - String fChannelId = EntityFolder.getNotificationChannelId(message.folder); - if (!channelIdDisabled.containsKey(fChannelId)) { - NotificationChannel channel = nm.getNotificationChannel(fChannelId); - channelIdDisabled.put(fChannelId, - channel != null && channel.getImportance() == NotificationManager.IMPORTANCE_NONE); - } - - if (Boolean.TRUE.equals(channelIdDisabled.get(fChannelId)) || - (mChannelId != null && Boolean.TRUE.equals(channelIdDisabled.get(mChannelId)))) { - db.message().setMessageUiIgnored(message.id, true); - Log.i("Notify disabled=" + message.id + - " " + mChannelId + "=" + channelIdDisabled.get(mChannelId) + - " " + fChannelId + "=" + channelIdDisabled.get(fChannelId)); - continue; - } - } - - if (notify_preview && notify_preview_only && !message.content) - continue; - - if (foreground && notify_background_only && message.notifying == 0) { - Log.i("Notify foreground=" + message.id); - if (!message.ui_ignored) - db.message().setMessageUiIgnored(message.id, true); - continue; - } - - long group = (pro && message.accountNotify ? message.account : 0); - if (!message.folderUnified) - group = -message.folder; - if (!data.groupNotifying.containsKey(group)) - data.groupNotifying.put(group, new ArrayList<>()); - if (!groupMessages.containsKey(group)) - groupMessages.put(group, new ArrayList<>()); - - if (message.notifying == 0) { - // Handle clear notifying on boot/update - EntityMessage msg = db.message().getMessage(message.id); - if (msg != null && msg.notifying == 0) { - Log.i("Notify boot=" + msg.id); - data.groupNotifying.get(group).remove(msg.id); - data.groupNotifying.get(group).remove(-msg.id); - } - } else { - long id = message.id * message.notifying; - if (!data.groupNotifying.get(group).contains(id) && - !data.groupNotifying.get(group).contains(-id)) { - Log.i("Notify database=" + id); - data.groupNotifying.get(group).add(id); - } - } - - if (message.ui_seen || message.ui_ignored || message.ui_hide) - Log.i("Notify id=" + message.id + - " seen=" + message.ui_seen + - " ignored=" + message.ui_ignored + - " hide=" + message.ui_hide); - else { - // Prevent reappearing notifications - EntityMessage msg = db.message().getMessage(message.id); - if (msg == null || msg.ui_ignored) { - Log.i("Notify skip id=" + message.id + " msg=" + (msg != null)); - continue; - } - - Integer current = newMessages.get(group); - newMessages.put(group, current == null ? 1 : current + 1); - - // This assumes the messages are properly ordered - if (groupMessages.get(group).size() < MAX_NOTIFICATION_COUNT) - groupMessages.get(group).add(message); - else - db.message().setMessageUiIgnored(message.id, true); - } - } - - // Difference - boolean flash = false; - for (long group : groupMessages.keySet()) { - List add = new ArrayList<>(); - List update = new ArrayList<>(); - List remove = new ArrayList<>(data.groupNotifying.get(group)); - for (int m = 0; m < groupMessages.get(group).size(); m++) { - TupleMessageEx message = groupMessages.get(group).get(m); - if (m >= MAX_NOTIFICATION_DISPLAY) { - // This is to prevent notification sounds when shifting messages up - if (!message.ui_silent) { - Log.i("Notify silence=" + message.id); - db.message().setMessageUiSilent(message.id, true); - } - continue; - } - - long id = (message.content ? message.id : -message.id); - if (remove.contains(id)) { - remove.remove(id); - Log.i("Notify existing=" + id); - } else { - boolean existing = remove.contains(-id); - if (existing) { - if (message.content && notify_preview) { - Log.i("Notify preview=" + id); - add.add(id); - update.add(id); - } - remove.remove(-id); - } else { - flash = true; - add.add(id); - } - Log.i("Notify adding=" + id + " existing=" + existing); - } - } - - Integer prev = prefs.getInt("new_messages." + group, 0); - Integer current = newMessages.get(group); - if (current == null) - current = 0; - prefs.edit().putInt("new_messages." + group, current).apply(); - - if (prev.equals(current) && - remove.size() + add.size() == 0) { - Log.i("Notify unchanged"); - continue; - } - - boolean summary = (notify_summary || - (group != 0 && - groupMessages.get(group).size() > 0 && - groupMessages.get(group).get(0).accountSummary)); - - // Build notifications - List notifications = getNotificationUnseen(context, - group, groupMessages.get(group), - summary, current - prev, current, - redacted); - - Log.i("Notify group=" + group + - " new=" + prev + "/" + current + - " count=" + notifications.size() + - " add=" + add.size() + - " update=" + update.size() + - " remove=" + remove.size()); - - for (Long id : remove) { - String tag = "unseen." + group + "." + Math.abs(id); - EntityLog.log(context, EntityLog.Type.Notification, - null, null, id == 0 ? null : Math.abs(id), - "Notify cancel tag=" + tag + " id=" + id); - nm.cancel(tag, NotificationHelper.NOTIFICATION_TAGGED); - - data.groupNotifying.get(group).remove(id); - db.message().setMessageNotifying(Math.abs(id), 0); - } - - if (notifications.size() == 0) { - String tag = "unseen." + group + "." + 0; - EntityLog.log(context, EntityLog.Type.Notification, - "Notify cancel tag=" + tag); - nm.cancel(tag, NotificationHelper.NOTIFICATION_TAGGED); - } - - for (Long id : add) { - data.groupNotifying.get(group).add(id); - data.groupNotifying.get(group).remove(-id); - db.message().setMessageNotifying(Math.abs(id), (int) Math.signum(id)); - } - - for (NotificationCompat.Builder builder : notifications) { - long id = builder.getExtras().getLong("id", 0); - if ((id == 0 && !prev.equals(current)) || add.contains(id)) { - // https://developer.android.com/training/wearables/notifications/bridger#non-bridged - if (id == 0) { - if (!summary) - builder.setLocalOnly(true); - } else { - if (wearable_preview ? id < 0 : update.contains(id)) - builder.setLocalOnly(true); - } - - String tag = "unseen." + group + "." + Math.abs(id); - Notification notification = builder.build(); - EntityLog.log(context, EntityLog.Type.Notification, - null, null, id == 0 ? null : Math.abs(id), - "Notifying tag=" + tag + - " id=" + id + " group=" + notification.getGroup() + - (Build.VERSION.SDK_INT < Build.VERSION_CODES.O - ? " sdk=" + Build.VERSION.SDK_INT - : " channel=" + notification.getChannelId()) + - " sort=" + notification.getSortKey()); - try { - if (NotificationHelper.areNotificationsEnabled(nm)) { - nm.notify(tag, NotificationHelper.NOTIFICATION_TAGGED, notification); - if (update.contains(id)) - try { - Log.i("Notify delay id=" + id); - Thread.sleep(NOTIFY_DELAY); - } catch (InterruptedException ex) { - Log.w(ex); - } - } - - // https://github.com/leolin310148/ShortcutBadger/wiki/Xiaomi-Device-Support - if (id == 0 && badge && Helper.isXiaomi()) - ShortcutBadgerAlt.applyNotification(context, notification, current); - } catch (Throwable ex) { - Log.w(ex); - } - } - } - } - - if (notify_screen_on && flash) { - Log.i("Notify screen on"); - PowerManager pm = Helper.getSystemService(context, PowerManager.class); - PowerManager.WakeLock wakeLock = pm.newWakeLock( - PowerManager.FULL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP, - BuildConfig.APPLICATION_ID + ":notification"); - wakeLock.acquire(SCREEN_ON_DURATION); - } - } - - private static List getNotificationUnseen( - Context context, - long group, List messages, - boolean notify_summary, int new_messages, int total_messages, boolean redacted) { - List notifications = new ArrayList<>(); - - // Android 7+ N https://developer.android.com/training/notify-user/group - // Android 8+ O https://developer.android.com/training/notify-user/channels - // Android 7+ N https://android-developers.googleblog.com/2016/06/notifications-in-android-n.html - - // Group - // < 0: folder - // = 0: unified - // > 0: account - - NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); - if (messages == null || messages.size() == 0 || nm == null) - return notifications; - - boolean pro = ActivityBilling.isPro(context); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - boolean notify_grouping = prefs.getBoolean("notify_grouping", true); - boolean notify_private = prefs.getBoolean("notify_private", true); - boolean notify_newest_first = prefs.getBoolean("notify_newest_first", false); - MessageHelper.AddressFormat email_format = MessageHelper.getAddressFormat(context); - boolean prefer_contact = prefs.getBoolean("prefer_contact", false); - boolean flags = prefs.getBoolean("flags", true); - boolean notify_messaging = prefs.getBoolean("notify_messaging", false); - boolean notify_subtext = prefs.getBoolean("notify_subtext", true); - boolean notify_preview = prefs.getBoolean("notify_preview", true); - boolean notify_preview_all = prefs.getBoolean("notify_preview_all", false); - boolean wearable_preview = prefs.getBoolean("wearable_preview", false); - boolean notify_trash = (prefs.getBoolean("notify_trash", true) || !pro); - boolean notify_junk = (prefs.getBoolean("notify_junk", false) && pro); - boolean notify_archive = (prefs.getBoolean("notify_archive", true) || !pro); - boolean notify_move = (prefs.getBoolean("notify_move", false) && pro); - boolean notify_reply = (prefs.getBoolean("notify_reply", false) && pro); - boolean notify_reply_direct = (prefs.getBoolean("notify_reply_direct", false) && pro); - boolean notify_flag = (prefs.getBoolean("notify_flag", false) && flags && pro); - boolean notify_seen = (prefs.getBoolean("notify_seen", true) || !pro); - boolean notify_hide = (prefs.getBoolean("notify_hide", false) && pro); - boolean notify_snooze = (prefs.getBoolean("notify_snooze", false) && pro); - boolean notify_remove = prefs.getBoolean("notify_remove", true); - boolean light = prefs.getBoolean("light", false); - String sound = prefs.getString("sound", null); - boolean alert_once = prefs.getBoolean("alert_once", true); - boolean perform_expunge = prefs.getBoolean("perform_expunge", true); - boolean delete_confirmation = prefs.getBoolean("delete_confirmation", true); - - - // Get contact info - Map messageFrom = new HashMap<>(); - Map messageInfo = new HashMap<>(); - for (int m = 0; m < messages.size() && m < MAX_NOTIFICATION_DISPLAY; m++) { - TupleMessageEx message = messages.get(m); - ContactInfo[] info = ContactInfo.get(context, - message.account, message.folderType, message.bimi_selector, - message.isForwarder() ? message.submitter : message.from); - - Address[] modified = (message.from == null - ? new InternetAddress[0] - : Arrays.copyOf(message.from, message.from.length)); - for (int i = 0; i < modified.length; i++) { - String displayName = info[i].getDisplayName(); - if (!TextUtils.isEmpty(displayName)) { - String email = ((InternetAddress) modified[i]).getAddress(); - String personal = ((InternetAddress) modified[i]).getPersonal(); - if (TextUtils.isEmpty(personal) || prefer_contact) - try { - modified[i] = new InternetAddress(email, displayName, StandardCharsets.UTF_8.name()); - } catch (UnsupportedEncodingException ex) { - Log.w(ex); - } - } - } - - messageInfo.put(message.id, info); - messageFrom.put(message.id, modified); - } - - // Summary notification - if (notify_summary || - (notify_grouping && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)) { - // Build pending intents - Intent content; - if (group < 0) { - content = new Intent(context, ActivityView.class) - .setAction("folder:" + (-group) + (notify_remove ? ":" + group : "")); - if (messages.size() > 0) - content.putExtra("type", messages.get(0).folderType); - } else - content = new Intent(context, ActivityView.class) - .setAction("unified" + (notify_remove ? ":" + group : "")); - content.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - PendingIntent piContent = PendingIntentCompat.getActivity( - context, ActivityView.PI_UNIFIED, content, PendingIntent.FLAG_UPDATE_CURRENT); - - Intent clear = new Intent(context, ServiceUI.class).setAction("clear:" + group); - PendingIntent piClear = PendingIntentCompat.getService( - context, ServiceUI.PI_CLEAR, clear, PendingIntent.FLAG_UPDATE_CURRENT); - - // Build title - String title = context.getResources().getQuantityString( - R.plurals.title_notification_unseen, total_messages, total_messages); - - long cgroup = (group >= 0 - ? group - : (pro && messages.size() > 0 && messages.get(0).accountNotify ? messages.get(0).account : 0)); - - // Build notification - NotificationCompat.Builder builder = - new NotificationCompat.Builder(context, EntityAccount.getNotificationChannelId(cgroup)) - .setSmallIcon(messages.size() > 1 - ? R.drawable.baseline_mail_more_white_24 - : R.drawable.baseline_mail_white_24) - .setContentTitle(title) - .setContentIntent(piContent) - .setNumber(total_messages) - .setDeleteIntent(piClear) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setCategory(notify_summary - ? NotificationCompat.CATEGORY_EMAIL : NotificationCompat.CATEGORY_STATUS) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setAllowSystemGeneratedContextualActions(false); - - if (group != 0 && messages.size() > 0) - builder.setSubText(messages.get(0).accountName); - - if (notify_summary) { - builder.setOnlyAlertOnce(new_messages <= 0); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - if (new_messages > 0) - setLightAndSound(builder, light, sound); - else - builder.setSound(null); - } else { - builder - .setGroup(Long.toString(group)) - .setGroupSummary(true) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - builder.setSound(null); - } - - if (pro) { - Integer color = null; - for (TupleMessageEx message : messages) { - Integer mcolor = getColor(message); - if (mcolor == null) { - color = null; - break; - } else if (color == null) - color = mcolor; - else if (!color.equals(mcolor)) { - color = null; - break; - } - } - - if (color != null) { - builder.setColor(color); - builder.setColorized(true); - } - } - - // Subtext should not be set, to show number of new messages - - if (notify_private) { - Notification pub = builder.build(); - builder - .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - .setPublicVersion(pub); - } - - if (notify_preview) - if (redacted) - builder.setContentText(context.getString(R.string.title_notification_redacted)); - else { - DateFormat DTF = Helper.getDateTimeInstance(context, SimpleDateFormat.SHORT, SimpleDateFormat.SHORT); - StringBuilder sb = new StringBuilder(); - for (EntityMessage message : messages) { - Address[] afrom = messageFrom.get(message.id); - String from = MessageHelper.formatAddresses(afrom, email_format, false); - sb.append("").append(Html.escapeHtml(from)).append(""); - if (!TextUtils.isEmpty(message.subject)) - sb.append(": ").append(Html.escapeHtml(message.subject)); - sb.append(" ").append(Html.escapeHtml(DTF.format(message.received))); - sb.append("
"); - } - - // Wearables - builder.setContentText(title); - - // Device - builder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(HtmlHelper.fromHtml(sb.toString(), context)) - .setSummaryText(title)); - } - - //builder.extend(new NotificationCompat.WearableExtender() - // .setDismissalId(BuildConfig.APPLICATION_ID)); - - notifications.add(builder); - } - - if (notify_summary) - return notifications; - - // Message notifications - for (int m = 0; m < messages.size() && m < MAX_NOTIFICATION_DISPLAY; m++) { - TupleMessageEx message = messages.get(m); - ContactInfo[] info = messageInfo.get(message.id); - - // Build arguments - long id = (message.content ? message.id : -message.id); - Bundle args = new Bundle(); - args.putLong("id", id); - - // Build pending intents - Intent thread = new Intent(context, ActivityView.class); - thread.setAction("thread:" + message.id); - thread.putExtra("group", group); - thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - thread.putExtra("account", message.account); - thread.putExtra("folder", message.folder); - thread.putExtra("type", message.folderType); - thread.putExtra("thread", message.thread); - thread.putExtra("filter_archive", !EntityFolder.ARCHIVE.equals(message.folderType)); - thread.putExtra("ignore", notify_remove); - PendingIntent piContent = PendingIntentCompat.getActivity( - context, ActivityView.PI_THREAD, thread, PendingIntent.FLAG_UPDATE_CURRENT); - - Intent ignore = new Intent(context, ServiceUI.class).setAction("ignore:" + message.id); - PendingIntent piIgnore = PendingIntentCompat.getService( - context, ServiceUI.PI_IGNORED, ignore, PendingIntent.FLAG_UPDATE_CURRENT); - - // Get channel name - String channelName = EntityAccount.getNotificationChannelId(0); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pro) { - NotificationChannel channel = null; - - String channelId = message.getNotificationChannelId(); - if (channelId != null) - channel = nm.getNotificationChannel(channelId); - - if (channel == null) - channel = nm.getNotificationChannel(EntityFolder.getNotificationChannelId(message.folder)); - - if (channel == null) { - if (message.accountNotify) - channelName = EntityAccount.getNotificationChannelId(message.account); - } else - channelName = channel.getId(); - } - - String sortKey = String.format(Locale.ROOT, "%13d", - notify_newest_first ? (10000000000000L - message.received) : message.received); - - NotificationCompat.Builder mbuilder = - new NotificationCompat.Builder(context, channelName) - .addExtras(args) - .setSmallIcon(R.drawable.baseline_mail_white_24) - .setContentIntent(piContent) - .setWhen(message.received) - .setShowWhen(true) - .setSortKey(sortKey) - .setDeleteIntent(piIgnore) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setCategory(NotificationCompat.CATEGORY_EMAIL) - .setVisibility(notify_private - ? NotificationCompat.VISIBILITY_PRIVATE - : NotificationCompat.VISIBILITY_PUBLIC) - .setOnlyAlertOnce(alert_once) - .setAllowSystemGeneratedContextualActions(false); - - if (message.ui_silent) { - mbuilder.setSilent(true); - Log.i("Notify silent=" + message.id); - } - if (message.ui_local_only) { - mbuilder.setLocalOnly(true); - Log.i("Notify local=" + message.id); - } - - if (notify_messaging) { - // https://developer.android.com/training/cars/messaging - String meName = MessageHelper.formatAddresses(message.to, email_format, false); - String youName = MessageHelper.formatAddresses(message.from, email_format, false); - - // Names cannot be empty - if (TextUtils.isEmpty(meName)) - meName = "-"; - if (TextUtils.isEmpty(youName)) - youName = "-"; - - Person.Builder me = new Person.Builder().setName(meName); - Person.Builder you = new Person.Builder().setName(youName); - - if (info[0].hasPhoto()) - you.setIcon(IconCompat.createWithBitmap(info[0].getPhotoBitmap())); - - if (info[0].hasLookupUri()) - you.setUri(info[0].getLookupUri().toString()); - - NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(me.build()); - - if (!TextUtils.isEmpty(message.subject)) - messagingStyle.setConversationTitle(message.subject); - - messagingStyle.addMessage( - notify_preview && message.preview != null ? message.preview : "", - message.received, - you.build()); - - mbuilder.setStyle(messagingStyle); - } - - if (notify_grouping && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - mbuilder - .setGroup(Long.toString(group)) - .setGroupSummary(false) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - setLightAndSound(mbuilder, light, sound); - - Address[] afrom = messageFrom.get(message.id); - String from = MessageHelper.formatAddresses(afrom, email_format, false); - mbuilder.setContentTitle(from); - if (notify_subtext) - if (message.folderUnified && EntityFolder.INBOX.equals(message.folderType)) - mbuilder.setSubText(message.accountName); - else - mbuilder.setSubText(message.accountName + " - " + message.getFolderName(context)); - - DB db = DB.getInstance(context); - - List wactions = new ArrayList<>(); - - if (notify_trash && - perform_expunge && - message.accountProtocol == EntityAccount.TYPE_IMAP) { - EntityFolder folder = db.folder().getFolderByType(message.account, EntityFolder.TRASH); - if (folder != null && !folder.id.equals(message.folder)) { - Intent trash = new Intent(context, ServiceUI.class) - .setAction("trash:" + message.id) - .putExtra("group", group); - PendingIntent piTrash = PendingIntentCompat.getService( - context, ServiceUI.PI_TRASH, trash, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionTrash = new NotificationCompat.Action.Builder( - R.drawable.twotone_delete_24, - context.getString(R.string.title_advanced_notify_action_trash), - piTrash) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionTrash.build()); - - wactions.add(actionTrash.build()); - } - } else if (notify_trash && - (!delete_confirmation || - (message.accountProtocol == EntityAccount.TYPE_POP && message.accountLeaveDeleted) || - (message.accountProtocol == EntityAccount.TYPE_IMAP && !perform_expunge))) { - Intent delete = new Intent(context, ServiceUI.class) - .setAction("delete:" + message.id) - .putExtra("group", group); - PendingIntent piDelete = PendingIntentCompat.getService( - context, ServiceUI.PI_DELETE, delete, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionDelete = new NotificationCompat.Action.Builder( - R.drawable.twotone_delete_forever_24, - context.getString(R.string.title_advanced_notify_action_delete), - piDelete) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionDelete.build()); - - wactions.add(actionDelete.build()); - } - - if (notify_junk && - message.accountProtocol == EntityAccount.TYPE_IMAP) { - EntityFolder folder = db.folder().getFolderByType(message.account, EntityFolder.JUNK); - if (folder != null && !folder.id.equals(message.folder)) { - Intent junk = new Intent(context, ServiceUI.class) - .setAction("junk:" + message.id) - .putExtra("group", group); - PendingIntent piJunk = PendingIntentCompat.getService( - context, ServiceUI.PI_JUNK, junk, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionJunk = new NotificationCompat.Action.Builder( - R.drawable.twotone_report_24, - context.getString(R.string.title_advanced_notify_action_junk), - piJunk) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionJunk.build()); - - wactions.add(actionJunk.build()); - } - } - - if (notify_archive && - message.accountProtocol == EntityAccount.TYPE_IMAP) { - EntityFolder folder = db.folder().getFolderByType(message.account, EntityFolder.ARCHIVE); - if (folder != null && !folder.id.equals(message.folder)) { - Intent archive = new Intent(context, ServiceUI.class) - .setAction("archive:" + message.id) - .putExtra("group", group); - PendingIntent piArchive = PendingIntentCompat.getService( - context, ServiceUI.PI_ARCHIVE, archive, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionArchive = new NotificationCompat.Action.Builder( - R.drawable.twotone_archive_24, - context.getString(R.string.title_advanced_notify_action_archive), - piArchive) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_ARCHIVE) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionArchive.build()); - - wactions.add(actionArchive.build()); - } - } - - if (notify_move && - message.accountProtocol == EntityAccount.TYPE_IMAP) { - EntityAccount account = db.account().getAccount(message.account); - if (account != null && account.move_to != null) { - EntityFolder folder = db.folder().getFolder(account.move_to); - if (folder != null && !folder.id.equals(message.folder)) { - Intent move = new Intent(context, ServiceUI.class) - .setAction("move:" + message.id) - .putExtra("group", group); - PendingIntent piMove = PendingIntentCompat.getService( - context, ServiceUI.PI_MOVE, move, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionMove = new NotificationCompat.Action.Builder( - R.drawable.twotone_folder_24, - folder.getDisplayName(context), - piMove) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionMove.build()); - - wactions.add(actionMove.build()); - } - } - } - - if (notify_reply && message.content) { - List identities = db.identity().getComposableIdentities(message.account); - if (identities != null && identities.size() > 0) { - Intent reply = new Intent(context, ActivityCompose.class) - .setAction("reply:" + message.id) - .putExtra("action", "reply") - .putExtra("reference", message.id) - .putExtra("group", group); - reply.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent piReply = PendingIntentCompat.getActivity( - context, ActivityCompose.PI_REPLY, reply, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionReply = new NotificationCompat.Action.Builder( - R.drawable.twotone_reply_24, - context.getString(R.string.title_advanced_notify_action_reply), - piReply) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(true) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionReply.build()); - } - } - - if (message.content && - message.identity != null && - message.from != null && message.from.length > 0 && - db.folder().getOutbox() != null) { - Intent reply = new Intent(context, ServiceUI.class) - .setPackage(BuildConfig.APPLICATION_ID) - .setAction("reply:" + message.id) - .putExtra("group", group); - PendingIntent piReply = PendingIntentCompat.getService( - context, ServiceUI.PI_REPLY_DIRECT, reply, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); - NotificationCompat.Action.Builder actionReply = new NotificationCompat.Action.Builder( - R.drawable.twotone_reply_24, - context.getString(R.string.title_advanced_notify_action_reply_direct), - piReply) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - RemoteInput.Builder input = new RemoteInput.Builder("text") - .setLabel(context.getString(R.string.title_advanced_notify_action_reply)); - actionReply.addRemoteInput(input.build()) - .setAllowGeneratedReplies(false); - if (notify_reply_direct) { - mbuilder.addAction(actionReply.build()); - wactions.add(actionReply.build()); - } else - mbuilder.addInvisibleAction(actionReply.build()); - } - - if (notify_flag) { - Intent flag = new Intent(context, ServiceUI.class) - .setAction("flag:" + message.id) - .putExtra("group", group); - PendingIntent piFlag = PendingIntentCompat.getService( - context, ServiceUI.PI_FLAG, flag, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionFlag = new NotificationCompat.Action.Builder( - R.drawable.baseline_star_24, - context.getString(R.string.title_advanced_notify_action_flag), - piFlag) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_UP) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionFlag.build()); - - wactions.add(actionFlag.build()); - } - - if (true) { - Intent seen = new Intent(context, ServiceUI.class) - .setAction("seen:" + message.id) - .putExtra("group", group); - PendingIntent piSeen = PendingIntentCompat.getService( - context, ServiceUI.PI_SEEN, seen, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionSeen = new NotificationCompat.Action.Builder( - R.drawable.twotone_visibility_24, - context.getString(R.string.title_advanced_notify_action_seen), - piSeen) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - if (notify_seen) { - mbuilder.addAction(actionSeen.build()); - wactions.add(actionSeen.build()); - } else - mbuilder.addInvisibleAction(actionSeen.build()); - } - - if (notify_hide) { - Intent hide = new Intent(context, ServiceUI.class) - .setAction("hide:" + message.id) - .putExtra("group", group); - PendingIntent piHide = PendingIntentCompat.getService( - context, ServiceUI.PI_HIDE, hide, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionHide = new NotificationCompat.Action.Builder( - R.drawable.twotone_visibility_off_24, - context.getString(R.string.title_advanced_notify_action_hide), - piHide) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionHide.build()); - - wactions.add(actionHide.build()); - } - - if (notify_snooze) { - Intent snooze = new Intent(context, ServiceUI.class) - .setAction("snooze:" + message.id) - .putExtra("group", group); - PendingIntent piSnooze = PendingIntentCompat.getService( - context, ServiceUI.PI_SNOOZE, snooze, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action.Builder actionSnooze = new NotificationCompat.Action.Builder( - R.drawable.twotone_timelapse_24, - context.getString(R.string.title_advanced_notify_action_snooze), - piSnooze) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MUTE) - .setShowsUserInterface(false) - .setAllowGeneratedReplies(false); - mbuilder.addAction(actionSnooze.build()); - - wactions.add(actionSnooze.build()); - } - - if (message.content && notify_preview) { - // Android will truncate the text - String preview = message.preview; - if (notify_preview_all) - try { - File file = message.getFile(context); - preview = HtmlHelper.getFullText(file); - if (preview != null && preview.length() > MAX_PREVIEW) - preview = preview.substring(0, MAX_PREVIEW); - } catch (Throwable ex) { - Log.e(ex); - } - - // Wearables - StringBuilder sb = new StringBuilder(); - if (!TextUtils.isEmpty(message.subject)) - sb.append(TextHelper.normalizeNotification(context, message.subject)); - if (wearable_preview && !TextUtils.isEmpty(preview)) { - if (sb.length() > 0) - sb.append(" - "); - sb.append(TextHelper.normalizeNotification(context, preview)); - } - if (sb.length() > 0) - mbuilder.setContentText(sb.toString()); - - // Device - if (!notify_messaging) { - StringBuilder sbm = new StringBuilder(); - - if (message.keywords != null && BuildConfig.DEBUG) - for (String keyword : message.keywords) - if (keyword.startsWith("!")) - sbm.append(Html.escapeHtml(keyword)).append(": "); - - if (!TextUtils.isEmpty(message.subject)) - sbm.append("").append(Html.escapeHtml(message.subject)).append("").append("
"); - - if (!TextUtils.isEmpty(preview)) - sbm.append(Html.escapeHtml(preview)); - - if (sbm.length() > 0) { - NotificationCompat.BigTextStyle bigText = new NotificationCompat.BigTextStyle() - .bigText(HtmlHelper.fromHtml(sbm.toString(), context)); - if (!TextUtils.isEmpty(message.subject)) - bigText.setSummaryText(message.subject); - - mbuilder.setStyle(bigText); - } - } - } else { - if (!TextUtils.isEmpty(message.subject)) - mbuilder.setContentText(TextHelper.normalizeNotification(context, message.subject)); - } - - if (info[0].hasPhoto()) - mbuilder.setLargeIcon(info[0].getPhotoBitmap()); - - if (info[0].hasLookupUri()) { - Person.Builder you = new Person.Builder() - .setUri(info[0].getLookupUri().toString()); - mbuilder.addPerson(you.build()); - } - - if (pro) { - Integer color = getColor(message); - if (color != null) { - mbuilder.setColor(color); - mbuilder.setColorized(true); - } - } - - // https://developer.android.com/training/wearables/notifications - // https://developer.android.com/reference/androidx/core/app/NotificationCompat.Action.WearableExtender - mbuilder.extend(new NotificationCompat.WearableExtender() - .addActions(wactions) - .setDismissalId(BuildConfig.APPLICATION_ID + ":" + id) - /* .setBridgeTag(id < 0 ? "header" : "body") */); - - // https://developer.android.com/reference/androidx/core/app/NotificationCompat.CarExtender - mbuilder.extend(new NotificationCompat.CarExtender()); - - notifications.add(mbuilder); - } - - return notifications; - } - - private static Integer getColor(TupleMessageEx message) { - if (!message.folderUnified && message.folderColor != null) - return message.folderColor; - return message.accountColor; - } - - private static void setLightAndSound(NotificationCompat.Builder builder, boolean light, String sound) { - int def = 0; - - if (light) { - def |= DEFAULT_LIGHTS; - Log.i("Notify light enabled"); - } - - if (!"".equals(sound)) { - // Not silent sound - Uri uri = (sound == null ? null : Uri.parse(sound)); - if (uri != null && !"content".equals(uri.getScheme())) - uri = null; - Log.i("Notify sound=" + uri); - - if (uri == null) - def |= DEFAULT_SOUND; - else - builder.setSound(uri); - } - - builder.setDefaults(def); - } - // FolderClosedException: can happen when no connectivity // IllegalStateException: @@ -6818,44 +5831,4 @@ class Core { " total=" + total + " ms"; } } - - static class NotificationData { - private Map> groupNotifying = new HashMap<>(); - - NotificationData(Context context) { - // Get existing notifications - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - try { - NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); - for (StatusBarNotification sbn : nm.getActiveNotifications()) { - String tag = sbn.getTag(); - if (tag != null && tag.startsWith("unseen.")) { - String[] p = tag.split(("\\.")); - long group = Long.parseLong(p[1]); - long id = sbn.getNotification().extras.getLong("id", 0); - - if (!groupNotifying.containsKey(group)) - groupNotifying.put(group, new ArrayList<>()); - - if (id > 0) { - EntityLog.log(context, EntityLog.Type.Notification, null, null, id, - "Notify restore " + tag + " id=" + id); - groupNotifying.get(group).add(id); - } - } - } - } catch (Throwable ex) { - Log.w(ex); - /* - java.lang.RuntimeException: Unable to create service eu.faircode.email.ServiceSynchronize: java.lang.NullPointerException: Attempt to invoke virtual method 'java.util.List android.content.pm.ParceledListSlice.getList()' on a null object reference - at android.app.ActivityThread.handleCreateService(ActivityThread.java:2944) - at android.app.ActivityThread.access$1900(ActivityThread.java:154) - at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1474) - at android.os.Handler.dispatchMessage(Handler.java:102) - at android.os.Looper.loop(Looper.java:234) - at android.app.ActivityThread.main(ActivityThread.java:5526) - */ - } - } - } } diff --git a/app/src/main/java/eu/faircode/email/NotificationHelper.java b/app/src/main/java/eu/faircode/email/NotificationHelper.java index 8d1f8d03cc..3bfa448717 100644 --- a/app/src/main/java/eu/faircode/email/NotificationHelper.java +++ b/app/src/main/java/eu/faircode/email/NotificationHelper.java @@ -19,27 +19,55 @@ package eu.faircode.email; Copyright 2018-2023 by Marcel Bokhorst (M66B) */ +import static androidx.core.app.NotificationCompat.DEFAULT_LIGHTS; +import static androidx.core.app.NotificationCompat.DEFAULT_SOUND; + import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; +import android.app.PendingIntent; import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; import android.graphics.Color; import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.service.notification.StatusBarNotification; +import android.text.Html; import android.text.TextUtils; import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.app.Person; +import androidx.core.app.RemoteInput; +import androidx.core.graphics.drawable.IconCompat; +import androidx.preference.PreferenceManager; import org.json.JSONException; import org.json.JSONObject; +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.mail.Address; +import javax.mail.internet.InternetAddress; + +import me.leolin.shortcutbadger.ShortcutBadgerAlt; class NotificationHelper { static final int NOTIFICATION_SYNCHRONIZE = 100; @@ -48,6 +76,17 @@ class NotificationHelper { static final int NOTIFICATION_UPDATE = 400; static final int NOTIFICATION_TAGGED = 500; + private static final int MAX_NOTIFICATION_DISPLAY = 10; // per group + private static final int MAX_NOTIFICATION_COUNT = 100; // per group + private static final long SCREEN_ON_DURATION = 3000L; // milliseconds + + // Android applies a rate limit when updating a notification. + // If you post updates to a notification too frequently—many in less than one second— + // the system might drop updates. + private static final int DEFAULT_MAX_NOTIFICATION_ENQUEUE_RATE = 5; // NotificationManagerService.java + private static final int MAX_PREVIEW = 5000; // characters + private static final long NOTIFY_DELAY = 1000L / DEFAULT_MAX_NOTIFICATION_ENQUEUE_RATE; // milliseconds + private static final List PERSISTENT_IDS = Collections.unmodifiableList(Arrays.asList( "service", "send", @@ -299,4 +338,1021 @@ class NotificationHelper { return channel; } + + static void notifyMessages(Context context, List messages, NotificationHelper.NotificationData data, boolean foreground) { + if (messages == null) + messages = new ArrayList<>(); + + NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); + if (nm == null) + return; + + DB db = DB.getInstance(context); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean badge = prefs.getBoolean("badge", true); + boolean notify_background_only = prefs.getBoolean("notify_background_only", false); + boolean notify_summary = prefs.getBoolean("notify_summary", false); + boolean notify_preview = prefs.getBoolean("notify_preview", true); + boolean notify_preview_only = prefs.getBoolean("notify_preview_only", false); + boolean notify_screen_on = prefs.getBoolean("notify_screen_on", false); + boolean wearable_preview = prefs.getBoolean("wearable_preview", false); + boolean biometrics = prefs.getBoolean("biometrics", false); + String pin = prefs.getString("pin", null); + boolean biometric_notify = prefs.getBoolean("biometrics_notify", true); + boolean pro = ActivityBilling.isPro(context); + + boolean redacted = ((biometrics || !TextUtils.isEmpty(pin)) && !biometric_notify); + if (redacted) + notify_summary = true; + if (notify_screen_on && + !(BuildConfig.DEBUG || + Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU || + Helper.hasPermission(context, "android.permission.TURN_SCREEN_ON"))) + notify_screen_on = false; + + Log.i("Notify messages=" + messages.size() + + " biometrics=" + biometrics + "/" + biometric_notify + + " summary=" + notify_summary); + + Map newMessages = new HashMap<>(); + + Map> groupMessages = new HashMap<>(); + for (long group : data.groupNotifying.keySet()) + groupMessages.put(group, new ArrayList<>()); + + Map channelIdDisabled = new HashMap<>(); + + // Current + for (TupleMessageEx message : messages) { + if (message.notifying == EntityMessage.NOTIFYING_IGNORE) { + Log.e("Notify ignore"); + continue; + } + + // Check if notification channel enabled + if (message.notifying == 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pro) { + // Disabling a channel for a sender or folder doesn't disable notifications + // because the (account) summary notification isn't disabled + // So, suppress notifications here + + String mChannelId = message.getNotificationChannelId(); + if (mChannelId != null && !channelIdDisabled.containsKey(mChannelId)) { + NotificationChannel channel = nm.getNotificationChannel(mChannelId); + channelIdDisabled.put(mChannelId, + channel != null && channel.getImportance() == NotificationManager.IMPORTANCE_NONE); + } + + String fChannelId = EntityFolder.getNotificationChannelId(message.folder); + if (!channelIdDisabled.containsKey(fChannelId)) { + NotificationChannel channel = nm.getNotificationChannel(fChannelId); + channelIdDisabled.put(fChannelId, + channel != null && channel.getImportance() == NotificationManager.IMPORTANCE_NONE); + } + + if (Boolean.TRUE.equals(channelIdDisabled.get(fChannelId)) || + (mChannelId != null && Boolean.TRUE.equals(channelIdDisabled.get(mChannelId)))) { + db.message().setMessageUiIgnored(message.id, true); + Log.i("Notify disabled=" + message.id + + " " + mChannelId + "=" + channelIdDisabled.get(mChannelId) + + " " + fChannelId + "=" + channelIdDisabled.get(fChannelId)); + continue; + } + } + + if (notify_preview && notify_preview_only && !message.content) + continue; + + if (foreground && notify_background_only && message.notifying == 0) { + Log.i("Notify foreground=" + message.id); + if (!message.ui_ignored) + db.message().setMessageUiIgnored(message.id, true); + continue; + } + + long group = (pro && message.accountNotify ? message.account : 0); + if (!message.folderUnified) + group = -message.folder; + if (!data.groupNotifying.containsKey(group)) + data.groupNotifying.put(group, new ArrayList<>()); + if (!groupMessages.containsKey(group)) + groupMessages.put(group, new ArrayList<>()); + + if (message.notifying == 0) { + // Handle clear notifying on boot/update + EntityMessage msg = db.message().getMessage(message.id); + if (msg != null && msg.notifying == 0) { + Log.i("Notify boot=" + msg.id); + data.groupNotifying.get(group).remove(msg.id); + data.groupNotifying.get(group).remove(-msg.id); + } + } else { + long id = message.id * message.notifying; + if (!data.groupNotifying.get(group).contains(id) && + !data.groupNotifying.get(group).contains(-id)) { + Log.i("Notify database=" + id); + data.groupNotifying.get(group).add(id); + } + } + + if (message.ui_seen || message.ui_ignored || message.ui_hide) + Log.i("Notify id=" + message.id + + " seen=" + message.ui_seen + + " ignored=" + message.ui_ignored + + " hide=" + message.ui_hide); + else { + // Prevent reappearing notifications + EntityMessage msg = db.message().getMessage(message.id); + if (msg == null || msg.ui_ignored) { + Log.i("Notify skip id=" + message.id + " msg=" + (msg != null)); + continue; + } + + Integer current = newMessages.get(group); + newMessages.put(group, current == null ? 1 : current + 1); + + // This assumes the messages are properly ordered + if (groupMessages.get(group).size() < MAX_NOTIFICATION_COUNT) + groupMessages.get(group).add(message); + else + db.message().setMessageUiIgnored(message.id, true); + } + } + + // Difference + boolean flash = false; + for (long group : groupMessages.keySet()) { + List add = new ArrayList<>(); + List update = new ArrayList<>(); + List remove = new ArrayList<>(data.groupNotifying.get(group)); + for (int m = 0; m < groupMessages.get(group).size(); m++) { + TupleMessageEx message = groupMessages.get(group).get(m); + if (m >= MAX_NOTIFICATION_DISPLAY) { + // This is to prevent notification sounds when shifting messages up + if (!message.ui_silent) { + Log.i("Notify silence=" + message.id); + db.message().setMessageUiSilent(message.id, true); + } + continue; + } + + long id = (message.content ? message.id : -message.id); + if (remove.contains(id)) { + remove.remove(id); + Log.i("Notify existing=" + id); + } else { + boolean existing = remove.contains(-id); + if (existing) { + if (message.content && notify_preview) { + Log.i("Notify preview=" + id); + add.add(id); + update.add(id); + } + remove.remove(-id); + } else { + flash = true; + add.add(id); + } + Log.i("Notify adding=" + id + " existing=" + existing); + } + } + + Integer prev = prefs.getInt("new_messages." + group, 0); + Integer current = newMessages.get(group); + if (current == null) + current = 0; + prefs.edit().putInt("new_messages." + group, current).apply(); + + if (prev.equals(current) && + remove.size() + add.size() == 0) { + Log.i("Notify unchanged"); + continue; + } + + boolean summary = (notify_summary || + (group != 0 && + groupMessages.get(group).size() > 0 && + groupMessages.get(group).get(0).accountSummary)); + + // Build notifications + List notifications = getNotificationUnseen(context, + group, groupMessages.get(group), + summary, current - prev, current, + redacted); + + Log.i("Notify group=" + group + + " new=" + prev + "/" + current + + " count=" + notifications.size() + + " add=" + add.size() + + " update=" + update.size() + + " remove=" + remove.size()); + + for (Long id : remove) { + String tag = "unseen." + group + "." + Math.abs(id); + EntityLog.log(context, EntityLog.Type.Notification, + null, null, id == 0 ? null : Math.abs(id), + "Notify cancel tag=" + tag + " id=" + id); + nm.cancel(tag, NotificationHelper.NOTIFICATION_TAGGED); + + data.groupNotifying.get(group).remove(id); + db.message().setMessageNotifying(Math.abs(id), 0); + } + + if (notifications.size() == 0) { + String tag = "unseen." + group + "." + 0; + EntityLog.log(context, EntityLog.Type.Notification, + "Notify cancel tag=" + tag); + nm.cancel(tag, NotificationHelper.NOTIFICATION_TAGGED); + } + + for (Long id : add) { + data.groupNotifying.get(group).add(id); + data.groupNotifying.get(group).remove(-id); + db.message().setMessageNotifying(Math.abs(id), (int) Math.signum(id)); + } + + for (NotificationCompat.Builder builder : notifications) { + long id = builder.getExtras().getLong("id", 0); + if ((id == 0 && !prev.equals(current)) || add.contains(id)) { + // https://developer.android.com/training/wearables/notifications/bridger#non-bridged + if (id == 0) { + if (!summary) + builder.setLocalOnly(true); + } else { + if (wearable_preview ? id < 0 : update.contains(id)) + builder.setLocalOnly(true); + } + + String tag = "unseen." + group + "." + Math.abs(id); + Notification notification = builder.build(); + EntityLog.log(context, EntityLog.Type.Notification, + null, null, id == 0 ? null : Math.abs(id), + "Notifying tag=" + tag + + " id=" + id + " group=" + notification.getGroup() + + (Build.VERSION.SDK_INT < Build.VERSION_CODES.O + ? " sdk=" + Build.VERSION.SDK_INT + : " channel=" + notification.getChannelId()) + + " sort=" + notification.getSortKey()); + try { + if (NotificationHelper.areNotificationsEnabled(nm)) { + nm.notify(tag, NotificationHelper.NOTIFICATION_TAGGED, notification); + if (update.contains(id)) + try { + Log.i("Notify delay id=" + id); + Thread.sleep(NOTIFY_DELAY); + } catch (InterruptedException ex) { + Log.w(ex); + } + } + + // https://github.com/leolin310148/ShortcutBadger/wiki/Xiaomi-Device-Support + if (id == 0 && badge && Helper.isXiaomi()) + ShortcutBadgerAlt.applyNotification(context, notification, current); + } catch (Throwable ex) { + Log.w(ex); + } + } + } + } + + if (notify_screen_on && flash) { + Log.i("Notify screen on"); + PowerManager pm = Helper.getSystemService(context, PowerManager.class); + PowerManager.WakeLock wakeLock = pm.newWakeLock( + PowerManager.FULL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP, + BuildConfig.APPLICATION_ID + ":notification"); + wakeLock.acquire(SCREEN_ON_DURATION); + } + } + + private static List getNotificationUnseen( + Context context, + long group, List messages, + boolean notify_summary, int new_messages, int total_messages, boolean redacted) { + List notifications = new ArrayList<>(); + + // Android 7+ N https://developer.android.com/training/notify-user/group + // Android 8+ O https://developer.android.com/training/notify-user/channels + // Android 7+ N https://android-developers.googleblog.com/2016/06/notifications-in-android-n.html + + // Group + // < 0: folder + // = 0: unified + // > 0: account + + NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); + if (messages == null || messages.size() == 0 || nm == null) + return notifications; + + boolean pro = ActivityBilling.isPro(context); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean notify_grouping = prefs.getBoolean("notify_grouping", true); + boolean notify_private = prefs.getBoolean("notify_private", true); + boolean notify_newest_first = prefs.getBoolean("notify_newest_first", false); + MessageHelper.AddressFormat email_format = MessageHelper.getAddressFormat(context); + boolean prefer_contact = prefs.getBoolean("prefer_contact", false); + boolean flags = prefs.getBoolean("flags", true); + boolean notify_messaging = prefs.getBoolean("notify_messaging", false); + boolean notify_subtext = prefs.getBoolean("notify_subtext", true); + boolean notify_preview = prefs.getBoolean("notify_preview", true); + boolean notify_preview_all = prefs.getBoolean("notify_preview_all", false); + boolean wearable_preview = prefs.getBoolean("wearable_preview", false); + boolean notify_trash = (prefs.getBoolean("notify_trash", true) || !pro); + boolean notify_junk = (prefs.getBoolean("notify_junk", false) && pro); + boolean notify_archive = (prefs.getBoolean("notify_archive", true) || !pro); + boolean notify_move = (prefs.getBoolean("notify_move", false) && pro); + boolean notify_reply = (prefs.getBoolean("notify_reply", false) && pro); + boolean notify_reply_direct = (prefs.getBoolean("notify_reply_direct", false) && pro); + boolean notify_flag = (prefs.getBoolean("notify_flag", false) && flags && pro); + boolean notify_seen = (prefs.getBoolean("notify_seen", true) || !pro); + boolean notify_hide = (prefs.getBoolean("notify_hide", false) && pro); + boolean notify_snooze = (prefs.getBoolean("notify_snooze", false) && pro); + boolean notify_remove = prefs.getBoolean("notify_remove", true); + boolean light = prefs.getBoolean("light", false); + String sound = prefs.getString("sound", null); + boolean alert_once = prefs.getBoolean("alert_once", true); + boolean perform_expunge = prefs.getBoolean("perform_expunge", true); + boolean delete_confirmation = prefs.getBoolean("delete_confirmation", true); + + + // Get contact info + Map messageFrom = new HashMap<>(); + Map messageInfo = new HashMap<>(); + for (int m = 0; m < messages.size() && m < MAX_NOTIFICATION_DISPLAY; m++) { + TupleMessageEx message = messages.get(m); + ContactInfo[] info = ContactInfo.get(context, + message.account, message.folderType, message.bimi_selector, + message.isForwarder() ? message.submitter : message.from); + + Address[] modified = (message.from == null + ? new InternetAddress[0] + : Arrays.copyOf(message.from, message.from.length)); + for (int i = 0; i < modified.length; i++) { + String displayName = info[i].getDisplayName(); + if (!TextUtils.isEmpty(displayName)) { + String email = ((InternetAddress) modified[i]).getAddress(); + String personal = ((InternetAddress) modified[i]).getPersonal(); + if (TextUtils.isEmpty(personal) || prefer_contact) + try { + modified[i] = new InternetAddress(email, displayName, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException ex) { + Log.w(ex); + } + } + } + + messageInfo.put(message.id, info); + messageFrom.put(message.id, modified); + } + + // Summary notification + if (notify_summary || + (notify_grouping && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)) { + // Build pending intents + Intent content; + if (group < 0) { + content = new Intent(context, ActivityView.class) + .setAction("folder:" + (-group) + (notify_remove ? ":" + group : "")); + if (messages.size() > 0) + content.putExtra("type", messages.get(0).folderType); + } else + content = new Intent(context, ActivityView.class) + .setAction("unified" + (notify_remove ? ":" + group : "")); + content.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + PendingIntent piContent = PendingIntentCompat.getActivity( + context, ActivityView.PI_UNIFIED, content, PendingIntent.FLAG_UPDATE_CURRENT); + + Intent clear = new Intent(context, ServiceUI.class).setAction("clear:" + group); + PendingIntent piClear = PendingIntentCompat.getService( + context, ServiceUI.PI_CLEAR, clear, PendingIntent.FLAG_UPDATE_CURRENT); + + // Build title + String title = context.getResources().getQuantityString( + R.plurals.title_notification_unseen, total_messages, total_messages); + + long cgroup = (group >= 0 + ? group + : (pro && messages.size() > 0 && messages.get(0).accountNotify ? messages.get(0).account : 0)); + + // Build notification + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context, EntityAccount.getNotificationChannelId(cgroup)) + .setSmallIcon(messages.size() > 1 + ? R.drawable.baseline_mail_more_white_24 + : R.drawable.baseline_mail_white_24) + .setContentTitle(title) + .setContentIntent(piContent) + .setNumber(total_messages) + .setDeleteIntent(piClear) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(notify_summary + ? NotificationCompat.CATEGORY_EMAIL : NotificationCompat.CATEGORY_STATUS) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAllowSystemGeneratedContextualActions(false); + + if (group != 0 && messages.size() > 0) + builder.setSubText(messages.get(0).accountName); + + if (notify_summary) { + builder.setOnlyAlertOnce(new_messages <= 0); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + if (new_messages > 0) + setLightAndSound(builder, light, sound); + else + builder.setSound(null); + } else { + builder + .setGroup(Long.toString(group)) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + builder.setSound(null); + } + + if (pro) { + Integer color = null; + for (TupleMessageEx message : messages) { + Integer mcolor = getColor(message); + if (mcolor == null) { + color = null; + break; + } else if (color == null) + color = mcolor; + else if (!color.equals(mcolor)) { + color = null; + break; + } + } + + if (color != null) { + builder.setColor(color); + builder.setColorized(true); + } + } + + // Subtext should not be set, to show number of new messages + + if (notify_private) { + Notification pub = builder.build(); + builder + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(pub); + } + + if (notify_preview) + if (redacted) + builder.setContentText(context.getString(R.string.title_notification_redacted)); + else { + DateFormat DTF = Helper.getDateTimeInstance(context, SimpleDateFormat.SHORT, SimpleDateFormat.SHORT); + StringBuilder sb = new StringBuilder(); + for (EntityMessage message : messages) { + Address[] afrom = messageFrom.get(message.id); + String from = MessageHelper.formatAddresses(afrom, email_format, false); + sb.append("").append(Html.escapeHtml(from)).append(""); + if (!TextUtils.isEmpty(message.subject)) + sb.append(": ").append(Html.escapeHtml(message.subject)); + sb.append(" ").append(Html.escapeHtml(DTF.format(message.received))); + sb.append("
"); + } + + // Wearables + builder.setContentText(title); + + // Device + builder.setStyle(new NotificationCompat.BigTextStyle() + .bigText(HtmlHelper.fromHtml(sb.toString(), context)) + .setSummaryText(title)); + } + + //builder.extend(new NotificationCompat.WearableExtender() + // .setDismissalId(BuildConfig.APPLICATION_ID)); + + notifications.add(builder); + } + + if (notify_summary) + return notifications; + + // Message notifications + for (int m = 0; m < messages.size() && m < MAX_NOTIFICATION_DISPLAY; m++) { + TupleMessageEx message = messages.get(m); + ContactInfo[] info = messageInfo.get(message.id); + + // Build arguments + long id = (message.content ? message.id : -message.id); + Bundle args = new Bundle(); + args.putLong("id", id); + + // Build pending intents + Intent thread = new Intent(context, ActivityView.class); + thread.setAction("thread:" + message.id); + thread.putExtra("group", group); + thread.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + thread.putExtra("account", message.account); + thread.putExtra("folder", message.folder); + thread.putExtra("type", message.folderType); + thread.putExtra("thread", message.thread); + thread.putExtra("filter_archive", !EntityFolder.ARCHIVE.equals(message.folderType)); + thread.putExtra("ignore", notify_remove); + PendingIntent piContent = PendingIntentCompat.getActivity( + context, ActivityView.PI_THREAD, thread, PendingIntent.FLAG_UPDATE_CURRENT); + + Intent ignore = new Intent(context, ServiceUI.class).setAction("ignore:" + message.id); + PendingIntent piIgnore = PendingIntentCompat.getService( + context, ServiceUI.PI_IGNORED, ignore, PendingIntent.FLAG_UPDATE_CURRENT); + + // Get channel name + String channelName = EntityAccount.getNotificationChannelId(0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pro) { + NotificationChannel channel = null; + + String channelId = message.getNotificationChannelId(); + if (channelId != null) + channel = nm.getNotificationChannel(channelId); + + if (channel == null) + channel = nm.getNotificationChannel(EntityFolder.getNotificationChannelId(message.folder)); + + if (channel == null) { + if (message.accountNotify) + channelName = EntityAccount.getNotificationChannelId(message.account); + } else + channelName = channel.getId(); + } + + String sortKey = String.format(Locale.ROOT, "%13d", + notify_newest_first ? (10000000000000L - message.received) : message.received); + + NotificationCompat.Builder mbuilder = + new NotificationCompat.Builder(context, channelName) + .addExtras(args) + .setSmallIcon(R.drawable.baseline_mail_white_24) + .setContentIntent(piContent) + .setWhen(message.received) + .setShowWhen(true) + .setSortKey(sortKey) + .setDeleteIntent(piIgnore) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setVisibility(notify_private + ? NotificationCompat.VISIBILITY_PRIVATE + : NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(alert_once) + .setAllowSystemGeneratedContextualActions(false); + + if (message.ui_silent) { + mbuilder.setSilent(true); + Log.i("Notify silent=" + message.id); + } + if (message.ui_local_only) { + mbuilder.setLocalOnly(true); + Log.i("Notify local=" + message.id); + } + + if (notify_messaging) { + // https://developer.android.com/training/cars/messaging + String meName = MessageHelper.formatAddresses(message.to, email_format, false); + String youName = MessageHelper.formatAddresses(message.from, email_format, false); + + // Names cannot be empty + if (TextUtils.isEmpty(meName)) + meName = "-"; + if (TextUtils.isEmpty(youName)) + youName = "-"; + + Person.Builder me = new Person.Builder().setName(meName); + Person.Builder you = new Person.Builder().setName(youName); + + if (info[0].hasPhoto()) + you.setIcon(IconCompat.createWithBitmap(info[0].getPhotoBitmap())); + + if (info[0].hasLookupUri()) + you.setUri(info[0].getLookupUri().toString()); + + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(me.build()); + + if (!TextUtils.isEmpty(message.subject)) + messagingStyle.setConversationTitle(message.subject); + + messagingStyle.addMessage( + notify_preview && message.preview != null ? message.preview : "", + message.received, + you.build()); + + mbuilder.setStyle(messagingStyle); + } + + if (notify_grouping && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + mbuilder + .setGroup(Long.toString(group)) + .setGroupSummary(false) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + setLightAndSound(mbuilder, light, sound); + + Address[] afrom = messageFrom.get(message.id); + String from = MessageHelper.formatAddresses(afrom, email_format, false); + mbuilder.setContentTitle(from); + if (notify_subtext) + if (message.folderUnified && EntityFolder.INBOX.equals(message.folderType)) + mbuilder.setSubText(message.accountName); + else + mbuilder.setSubText(message.accountName + " - " + message.getFolderName(context)); + + DB db = DB.getInstance(context); + + List wactions = new ArrayList<>(); + + if (notify_trash && + perform_expunge && + message.accountProtocol == EntityAccount.TYPE_IMAP) { + EntityFolder folder = db.folder().getFolderByType(message.account, EntityFolder.TRASH); + if (folder != null && !folder.id.equals(message.folder)) { + Intent trash = new Intent(context, ServiceUI.class) + .setAction("trash:" + message.id) + .putExtra("group", group); + PendingIntent piTrash = PendingIntentCompat.getService( + context, ServiceUI.PI_TRASH, trash, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionTrash = new NotificationCompat.Action.Builder( + R.drawable.twotone_delete_24, + context.getString(R.string.title_advanced_notify_action_trash), + piTrash) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionTrash.build()); + + wactions.add(actionTrash.build()); + } + } else if (notify_trash && + (!delete_confirmation || + (message.accountProtocol == EntityAccount.TYPE_POP && message.accountLeaveDeleted) || + (message.accountProtocol == EntityAccount.TYPE_IMAP && !perform_expunge))) { + Intent delete = new Intent(context, ServiceUI.class) + .setAction("delete:" + message.id) + .putExtra("group", group); + PendingIntent piDelete = PendingIntentCompat.getService( + context, ServiceUI.PI_DELETE, delete, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionDelete = new NotificationCompat.Action.Builder( + R.drawable.twotone_delete_forever_24, + context.getString(R.string.title_advanced_notify_action_delete), + piDelete) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionDelete.build()); + + wactions.add(actionDelete.build()); + } + + if (notify_junk && + message.accountProtocol == EntityAccount.TYPE_IMAP) { + EntityFolder folder = db.folder().getFolderByType(message.account, EntityFolder.JUNK); + if (folder != null && !folder.id.equals(message.folder)) { + Intent junk = new Intent(context, ServiceUI.class) + .setAction("junk:" + message.id) + .putExtra("group", group); + PendingIntent piJunk = PendingIntentCompat.getService( + context, ServiceUI.PI_JUNK, junk, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionJunk = new NotificationCompat.Action.Builder( + R.drawable.twotone_report_24, + context.getString(R.string.title_advanced_notify_action_junk), + piJunk) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionJunk.build()); + + wactions.add(actionJunk.build()); + } + } + + if (notify_archive && + message.accountProtocol == EntityAccount.TYPE_IMAP) { + EntityFolder folder = db.folder().getFolderByType(message.account, EntityFolder.ARCHIVE); + if (folder != null && !folder.id.equals(message.folder)) { + Intent archive = new Intent(context, ServiceUI.class) + .setAction("archive:" + message.id) + .putExtra("group", group); + PendingIntent piArchive = PendingIntentCompat.getService( + context, ServiceUI.PI_ARCHIVE, archive, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionArchive = new NotificationCompat.Action.Builder( + R.drawable.twotone_archive_24, + context.getString(R.string.title_advanced_notify_action_archive), + piArchive) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_ARCHIVE) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionArchive.build()); + + wactions.add(actionArchive.build()); + } + } + + if (notify_move && + message.accountProtocol == EntityAccount.TYPE_IMAP) { + EntityAccount account = db.account().getAccount(message.account); + if (account != null && account.move_to != null) { + EntityFolder folder = db.folder().getFolder(account.move_to); + if (folder != null && !folder.id.equals(message.folder)) { + Intent move = new Intent(context, ServiceUI.class) + .setAction("move:" + message.id) + .putExtra("group", group); + PendingIntent piMove = PendingIntentCompat.getService( + context, ServiceUI.PI_MOVE, move, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionMove = new NotificationCompat.Action.Builder( + R.drawable.twotone_folder_24, + folder.getDisplayName(context), + piMove) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionMove.build()); + + wactions.add(actionMove.build()); + } + } + } + + if (notify_reply && message.content) { + List identities = db.identity().getComposableIdentities(message.account); + if (identities != null && identities.size() > 0) { + Intent reply = new Intent(context, ActivityCompose.class) + .setAction("reply:" + message.id) + .putExtra("action", "reply") + .putExtra("reference", message.id) + .putExtra("group", group); + reply.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent piReply = PendingIntentCompat.getActivity( + context, ActivityCompose.PI_REPLY, reply, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionReply = new NotificationCompat.Action.Builder( + R.drawable.twotone_reply_24, + context.getString(R.string.title_advanced_notify_action_reply), + piReply) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(true) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionReply.build()); + } + } + + if (message.content && + message.identity != null && + message.from != null && message.from.length > 0 && + db.folder().getOutbox() != null) { + Intent reply = new Intent(context, ServiceUI.class) + .setPackage(BuildConfig.APPLICATION_ID) + .setAction("reply:" + message.id) + .putExtra("group", group); + PendingIntent piReply = PendingIntentCompat.getService( + context, ServiceUI.PI_REPLY_DIRECT, reply, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); + NotificationCompat.Action.Builder actionReply = new NotificationCompat.Action.Builder( + R.drawable.twotone_reply_24, + context.getString(R.string.title_advanced_notify_action_reply_direct), + piReply) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + RemoteInput.Builder input = new RemoteInput.Builder("text") + .setLabel(context.getString(R.string.title_advanced_notify_action_reply)); + actionReply.addRemoteInput(input.build()) + .setAllowGeneratedReplies(false); + if (notify_reply_direct) { + mbuilder.addAction(actionReply.build()); + wactions.add(actionReply.build()); + } else + mbuilder.addInvisibleAction(actionReply.build()); + } + + if (notify_flag) { + Intent flag = new Intent(context, ServiceUI.class) + .setAction("flag:" + message.id) + .putExtra("group", group); + PendingIntent piFlag = PendingIntentCompat.getService( + context, ServiceUI.PI_FLAG, flag, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionFlag = new NotificationCompat.Action.Builder( + R.drawable.baseline_star_24, + context.getString(R.string.title_advanced_notify_action_flag), + piFlag) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_THUMBS_UP) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionFlag.build()); + + wactions.add(actionFlag.build()); + } + + if (true) { + Intent seen = new Intent(context, ServiceUI.class) + .setAction("seen:" + message.id) + .putExtra("group", group); + PendingIntent piSeen = PendingIntentCompat.getService( + context, ServiceUI.PI_SEEN, seen, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionSeen = new NotificationCompat.Action.Builder( + R.drawable.twotone_visibility_24, + context.getString(R.string.title_advanced_notify_action_seen), + piSeen) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + if (notify_seen) { + mbuilder.addAction(actionSeen.build()); + wactions.add(actionSeen.build()); + } else + mbuilder.addInvisibleAction(actionSeen.build()); + } + + if (notify_hide) { + Intent hide = new Intent(context, ServiceUI.class) + .setAction("hide:" + message.id) + .putExtra("group", group); + PendingIntent piHide = PendingIntentCompat.getService( + context, ServiceUI.PI_HIDE, hide, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionHide = new NotificationCompat.Action.Builder( + R.drawable.twotone_visibility_off_24, + context.getString(R.string.title_advanced_notify_action_hide), + piHide) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionHide.build()); + + wactions.add(actionHide.build()); + } + + if (notify_snooze) { + Intent snooze = new Intent(context, ServiceUI.class) + .setAction("snooze:" + message.id) + .putExtra("group", group); + PendingIntent piSnooze = PendingIntentCompat.getService( + context, ServiceUI.PI_SNOOZE, snooze, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Action.Builder actionSnooze = new NotificationCompat.Action.Builder( + R.drawable.twotone_timelapse_24, + context.getString(R.string.title_advanced_notify_action_snooze), + piSnooze) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MUTE) + .setShowsUserInterface(false) + .setAllowGeneratedReplies(false); + mbuilder.addAction(actionSnooze.build()); + + wactions.add(actionSnooze.build()); + } + + if (message.content && notify_preview) { + // Android will truncate the text + String preview = message.preview; + if (notify_preview_all) + try { + File file = message.getFile(context); + preview = HtmlHelper.getFullText(file); + if (preview != null && preview.length() > MAX_PREVIEW) + preview = preview.substring(0, MAX_PREVIEW); + } catch (Throwable ex) { + Log.e(ex); + } + + // Wearables + StringBuilder sb = new StringBuilder(); + if (!TextUtils.isEmpty(message.subject)) + sb.append(TextHelper.normalizeNotification(context, message.subject)); + if (wearable_preview && !TextUtils.isEmpty(preview)) { + if (sb.length() > 0) + sb.append(" - "); + sb.append(TextHelper.normalizeNotification(context, preview)); + } + if (sb.length() > 0) + mbuilder.setContentText(sb.toString()); + + // Device + if (!notify_messaging) { + StringBuilder sbm = new StringBuilder(); + + if (message.keywords != null && BuildConfig.DEBUG) + for (String keyword : message.keywords) + if (keyword.startsWith("!")) + sbm.append(Html.escapeHtml(keyword)).append(": "); + + if (!TextUtils.isEmpty(message.subject)) + sbm.append("").append(Html.escapeHtml(message.subject)).append("").append("
"); + + if (!TextUtils.isEmpty(preview)) + sbm.append(Html.escapeHtml(preview)); + + if (sbm.length() > 0) { + NotificationCompat.BigTextStyle bigText = new NotificationCompat.BigTextStyle() + .bigText(HtmlHelper.fromHtml(sbm.toString(), context)); + if (!TextUtils.isEmpty(message.subject)) + bigText.setSummaryText(message.subject); + + mbuilder.setStyle(bigText); + } + } + } else { + if (!TextUtils.isEmpty(message.subject)) + mbuilder.setContentText(TextHelper.normalizeNotification(context, message.subject)); + } + + if (info[0].hasPhoto()) + mbuilder.setLargeIcon(info[0].getPhotoBitmap()); + + if (info[0].hasLookupUri()) { + Person.Builder you = new Person.Builder() + .setUri(info[0].getLookupUri().toString()); + mbuilder.addPerson(you.build()); + } + + if (pro) { + Integer color = getColor(message); + if (color != null) { + mbuilder.setColor(color); + mbuilder.setColorized(true); + } + } + + // https://developer.android.com/training/wearables/notifications + // https://developer.android.com/reference/androidx/core/app/NotificationCompat.Action.WearableExtender + mbuilder.extend(new NotificationCompat.WearableExtender() + .addActions(wactions) + .setDismissalId(BuildConfig.APPLICATION_ID + ":" + id) + /* .setBridgeTag(id < 0 ? "header" : "body") */); + + // https://developer.android.com/reference/androidx/core/app/NotificationCompat.CarExtender + mbuilder.extend(new NotificationCompat.CarExtender()); + + notifications.add(mbuilder); + } + + return notifications; + } + + private static Integer getColor(TupleMessageEx message) { + if (!message.folderUnified && message.folderColor != null) + return message.folderColor; + return message.accountColor; + } + + private static void setLightAndSound(NotificationCompat.Builder builder, boolean light, String sound) { + int def = 0; + + if (light) { + def |= DEFAULT_LIGHTS; + Log.i("Notify light enabled"); + } + + if (!"".equals(sound)) { + // Not silent sound + Uri uri = (sound == null ? null : Uri.parse(sound)); + if (uri != null && !"content".equals(uri.getScheme())) + uri = null; + Log.i("Notify sound=" + uri); + + if (uri == null) + def |= DEFAULT_SOUND; + else + builder.setSound(uri); + } + + builder.setDefaults(def); + } + + static class NotificationData { + private Map> groupNotifying = new HashMap<>(); + + NotificationData(Context context) { + // Get existing notifications + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + try { + NotificationManager nm = Helper.getSystemService(context, NotificationManager.class); + for (StatusBarNotification sbn : nm.getActiveNotifications()) { + String tag = sbn.getTag(); + if (tag != null && tag.startsWith("unseen.")) { + String[] p = tag.split(("\\.")); + long group = Long.parseLong(p[1]); + long id = sbn.getNotification().extras.getLong("id", 0); + + if (!groupNotifying.containsKey(group)) + groupNotifying.put(group, new ArrayList<>()); + + if (id > 0) { + EntityLog.log(context, EntityLog.Type.Notification, null, null, id, + "Notify restore " + tag + " id=" + id); + groupNotifying.get(group).add(id); + } + } + } + } catch (Throwable ex) { + Log.w(ex); + /* + java.lang.RuntimeException: Unable to create service eu.faircode.email.ServiceSynchronize: java.lang.NullPointerException: Attempt to invoke virtual method 'java.util.List android.content.pm.ParceledListSlice.getList()' on a null object reference + at android.app.ActivityThread.handleCreateService(ActivityThread.java:2944) + at android.app.ActivityThread.access$1900(ActivityThread.java:154) + at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1474) + at android.os.Handler.dispatchMessage(Handler.java:102) + at android.os.Looper.loop(Looper.java:234) + at android.app.ActivityThread.main(ActivityThread.java:5526) + */ + } + } + } } diff --git a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java index 65331eefcf..49dea8e4a9 100644 --- a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java +++ b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java @@ -811,7 +811,7 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences // New message notifications batching - Core.NotificationData notificationData = new Core.NotificationData(this); + NotificationHelper.NotificationData notificationData = new NotificationHelper.NotificationData(this); MutableLiveData> mutableUnseenNotify = new MutableLiveData<>(); db.message().liveUnseenNotify().observe(cowner, new Observer>() { @@ -884,7 +884,7 @@ public class ServiceSynchronize extends ServiceBase implements SharedPreferences public void delegate() { try { boolean fg = Boolean.TRUE.equals(foreground.getValue()); - Core.notifyMessages(ServiceSynchronize.this, messages, notificationData, fg); + NotificationHelper.notifyMessages(ServiceSynchronize.this, messages, notificationData, fg); } catch (SecurityException ex) { Log.w(ex); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSynchronize.this);