diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java deleted file mode 100644 index 26e3667542..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ /dev/null @@ -1,735 +0,0 @@ -/* - * Copyright (C) 2011 Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.notifications; - -import static org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.os.AsyncTask; -import android.os.Build; -import android.service.notification.StatusBarNotification; -import android.text.SpannableString; -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import com.annimon.stream.Optional; -import com.annimon.stream.Stream; -import com.goterl.lazysodium.utils.KeyPair; -import com.squareup.phrase.Phrase; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.ListIterator; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import me.leolin.shortcutbadger.ShortcutBadger; -import network.loki.messenger.R; -import org.session.libsession.messaging.open_groups.OpenGroup; -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.messaging.utilities.AccountId; -import org.session.libsession.messaging.utilities.SodiumUtilities; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.Contact; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.IdPrefix; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.Util; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.contacts.ContactUtil; -import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; -import org.thoughtcrime.securesms.crypto.KeyPairUtilities; -import org.thoughtcrime.securesms.database.LokiThreadDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.database.model.Quote; -import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.SlideDeck; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.SessionMetaProtocol; -import org.thoughtcrime.securesms.util.SpanUtil; - -/** - * Handles posting system notifications for new messages. - * - * - * @author Moxie Marlinspike - */ - -public class DefaultMessageNotifier implements MessageNotifier { - - private static final String TAG = DefaultMessageNotifier.class.getSimpleName(); - - public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; - public static final String LATEST_MESSAGE_ID_TAG = "extra_latest_message_id"; - - private static final int FOREGROUND_ID = 313399; - private static final int SUMMARY_NOTIFICATION_ID = 1338; - private static final int PENDING_MESSAGES_ID = 1111; - private static final String NOTIFICATION_GROUP = "messages"; - private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(5); - private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); - - private volatile static long visibleThread = -1; - private volatile static boolean homeScreenVisible = false; - private volatile static long lastDesktopActivityTimestamp = -1; - private volatile static long lastAudibleNotification = -1; - private static final CancelableExecutor executor = new CancelableExecutor(); - - @Override - public void setVisibleThread(long threadId) { - visibleThread = threadId; - } - - @Override - public void setHomeScreenVisible(boolean isVisible) { - homeScreenVisible = isVisible; - } - - @Override - public void setLastDesktopActivityTimestamp(long timestamp) { - lastDesktopActivityTimestamp = timestamp; - } - - @Override - public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { - // We do not provide notifications for message delivery failure. - } - - @Override - public void cancelDelayedNotifications() { - executor.cancel(); - } - - private boolean cancelActiveNotifications(@NonNull Context context) { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - boolean hasNotifications = notifications.getActiveNotifications().length > 0; - notifications.cancel(SUMMARY_NOTIFICATION_ID); - - try { - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - - for (StatusBarNotification activeNotification : activeNotifications) { - notifications.cancel(activeNotification.getId()); - } - } catch (Throwable e) { - // XXX Appears to be a ROM bug, see #6043 - Log.w(TAG, e); - notifications.cancelAll(); - } - return hasNotifications; - } - - private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { - try { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - - for (StatusBarNotification notification : activeNotifications) { - boolean validNotification = false; - - if (notification.getId() != SUMMARY_NOTIFICATION_ID && - notification.getId() != KeyCachingService.SERVICE_RUNNING_ID && - notification.getId() != FOREGROUND_ID && - notification.getId() != PENDING_MESSAGES_ID) - { - for (NotificationItem item : notificationState.getNotifications()) { - if (notification.getId() == (SUMMARY_NOTIFICATION_ID + item.getThreadId())) { - validNotification = true; - break; - } - } - - if (!validNotification) { notifications.cancel(notification.getId()); } - } - } - } catch (Throwable e) { - // XXX Android ROM Bug, see #6043 - Log.w(TAG, e); - } - } - - @Override - public void updateNotification(@NonNull Context context) { - if (!TextSecurePreferences.isNotificationsEnabled(context)) { - return; - } - - updateNotification(context, false, 0); - } - - @Override - public void updateNotification(@NonNull Context context, long threadId) - { - if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { - Log.i(TAG, "Scheduling delayed notification..."); - executor.execute(new DelayedNotification(context, threadId)); - } else { - updateNotification(context, threadId, true); - } - } - - @Override - public void updateNotification(@NonNull Context context, long threadId, boolean signal) - { - boolean isVisible = visibleThread == threadId; - - ThreadDatabase threads = DatabaseComponent.get(context).threadDatabase(); - Recipient recipient = threads.getRecipientForThreadId(threadId); - - if (recipient != null && !recipient.isGroupRecipient() && threads.getMessageCount(threadId) == 1 && - !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { - TextSecurePreferences.removeHasHiddenMessageRequests(context); - } - - if (!TextSecurePreferences.isNotificationsEnabled(context) || - (recipient != null && recipient.isMuted())) - { - return; - } - - if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { - updateNotification(context, signal, 0); - } - } - - private boolean hasExistingNotifications(Context context) { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - try { - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - return activeNotifications.length > 0; - } catch (Exception e) { - return false; - } - } - - @Override - public void updateNotification(@NonNull Context context, boolean signal, int reminderCount) - { - Cursor telcoCursor = null; - Cursor pushCursor = null; - - try { - telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); // TODO: add a notification specific lighter query here - - if ((telcoCursor == null || telcoCursor.isAfterLast()) || TextSecurePreferences.getLocalNumber(context) == null) - { - updateBadge(context, 0); - cancelActiveNotifications(context); - clearReminder(context); - return; - } - - NotificationState notificationState = constructNotificationState(context, telcoCursor); - - if (signal && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) { - signal = false; - } else if (signal) { - lastAudibleNotification = System.currentTimeMillis(); - } - - try { - if (notificationState.hasMultipleThreads()) { - for (long threadId : notificationState.getThreads()) { - sendSingleThreadNotification(context, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true); - } - sendMultipleThreadNotification(context, notificationState, signal); - } else if (notificationState.getMessageCount() > 0) { - sendSingleThreadNotification(context, notificationState, signal, false); - } else { - cancelActiveNotifications(context); - } - } catch (Exception e) { - Log.e(TAG, "Error creating notification", e); - } - cancelOrphanedNotifications(context, notificationState); - updateBadge(context, notificationState.getMessageCount()); - - if (signal) { - scheduleReminder(context, reminderCount); - } - } finally { - if (telcoCursor != null) telcoCursor.close(); - } - } - - private void sendSingleThreadNotification(@NonNull Context context, - @NonNull NotificationState notificationState, - boolean signal, boolean bundled) - { - Log.i(TAG, "sendSingleThreadNotification() signal: " + signal + " bundled: " + bundled); - - if (notificationState.getNotifications().isEmpty()) { - if (!bundled) cancelActiveNotifications(context); - Log.i(TAG, "Empty notification state. Skipping."); - return; - } - - SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); - List notifications = notificationState.getNotifications(); - Recipient recipient = notifications.get(0).getRecipient(); - int notificationId = (int) (SUMMARY_NOTIFICATION_ID + (bundled ? notifications.get(0).getThreadId() : 0)); - String messageIdTag = String.valueOf(notifications.get(0).getTimestamp()); - - NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); - for (StatusBarNotification notification: notificationManager.getActiveNotifications()) { - if ( (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && notification.isAppGroup() == bundled) - && messageIdTag.equals(notification.getNotification().extras.getString(LATEST_MESSAGE_ID_TAG))) { - return; - } - } - - long timestamp = notifications.get(0).getTimestamp(); - if (timestamp != 0) builder.setWhen(timestamp); - - builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); - - CharSequence text = notifications.get(0).getText(); - - builder.setThread(notifications.get(0).getRecipient()); - builder.setMessageCount(notificationState.getMessageCount()); - - CharSequence builderCS = text == null ? "" : text; - SpannableString ss = MentionUtilities.highlightMentions( - builderCS, - false, - false, - true, - bundled ? notifications.get(0).getThreadId() : 0, - context - ); - - builder.setPrimaryMessageBody(recipient, - notifications.get(0).getIndividualRecipient(), - ss, - notifications.get(0).getSlideDeck()); - - builder.setContentIntent(notifications.get(0).getPendingIntent(context)); - builder.setDeleteIntent(notificationState.getDeleteIntent(context)); - builder.setOnlyAlertOnce(!signal); - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - builder.setAutoCancel(true); - - ReplyMethod replyMethod = ReplyMethod.forRecipient(context, recipient); - - boolean canReply = SessionMetaProtocol.canUserReplyToNotification(recipient); - - PendingIntent quickReplyIntent = canReply ? notificationState.getQuickReplyIntent(context, recipient) : null; - PendingIntent remoteReplyIntent = canReply ? notificationState.getRemoteReplyIntent(context, recipient, replyMethod) : null; - - builder.addActions(notificationState.getMarkAsReadIntent(context, notificationId), - quickReplyIntent, - remoteReplyIntent, - replyMethod); - - if (canReply) { - builder.addAndroidAutoAction(notificationState.getAndroidAutoReplyIntent(context, recipient), - notificationState.getAndroidAutoHeardIntent(context, notificationId), - notifications.get(0).getTimestamp()); - } - - ListIterator iterator = notifications.listIterator(notifications.size()); - - while(iterator.hasPrevious()) { - NotificationItem item = iterator.previous(); - builder.addMessageBody(item.getRecipient(), item.getIndividualRecipient(), item.getText()); - } - - if (signal) { - builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); - builder.setTicker(notifications.get(0).getIndividualRecipient(), - notifications.get(0).getText()); - } - - if (bundled) { - builder.setGroup(NOTIFICATION_GROUP); - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - } - - Notification notification = builder.build(); - - // ACL FIX THIS PROPERLY - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // TODO: Consider calling - // ActivityCompat#requestPermissions - // here to request the missing permissions, and then overriding - // public void onRequestPermissionsResult(int requestCode, String[] permissions, - // int[] grantResults) - // to handle the case where the user grants the permission. See the documentation - // for ActivityCompat#requestPermissions for more details. - return; - } - NotificationManagerCompat.from(context).notify(notificationId, notification); - Log.i(TAG, "Posted notification. " + notification.toString()); - } - - private void sendMultipleThreadNotification(@NonNull Context context, - @NonNull NotificationState notificationState, - boolean signal) - { - Log.i(TAG, "sendMultiThreadNotification() signal: " + signal); - - MultipleRecipientNotificationBuilder builder = new MultipleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); - List notifications = notificationState.getNotifications(); - - builder.setMessageCount(notificationState.getMessageCount(), notificationState.getThreadCount()); - builder.setMostRecentSender(notifications.get(0).getIndividualRecipient(), notifications.get(0).getRecipient()); - builder.setGroup(NOTIFICATION_GROUP); - builder.setDeleteIntent(notificationState.getDeleteIntent(context)); - builder.setOnlyAlertOnce(!signal); - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); - builder.setAutoCancel(true); - - String messageIdTag = String.valueOf(notifications.get(0).getTimestamp()); - - NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); - for (StatusBarNotification notification: notificationManager.getActiveNotifications()) { - if (notification.getId() == SUMMARY_NOTIFICATION_ID - && messageIdTag.equals(notification.getNotification().extras.getString(LATEST_MESSAGE_ID_TAG))) { - return; - } - } - - long timestamp = notifications.get(0).getTimestamp(); - if (timestamp != 0) builder.setWhen(timestamp); - - builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID)); - - ListIterator iterator = notifications.listIterator(notifications.size()); - - while(iterator.hasPrevious()) { - NotificationItem item = iterator.previous(); - builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(), - MentionUtilities.highlightMentions( - item.getText() != null ? item.getText() : "", - false, - false, - true, // no styling here, only text formatting - item.getThreadId(), - context - ) - ); - } - - if (signal) { - builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate()); - CharSequence text = notifications.get(0).getText(); - builder.setTicker(notifications.get(0).getIndividualRecipient(), - MentionUtilities.highlightMentions( - text != null ? text : "", - false, - false, - true, // no styling here, only text formatting - notifications.get(0).getThreadId(), - context - ) - ); - } - - builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag); - - - // ACL FIX THIS PROPERLY - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // TODO: Consider calling - // ActivityCompat#requestPermissions - // here to request the missing permissions, and then overriding - // public void onRequestPermissionsResult(int requestCode, String[] permissions, - // int[] grantResults) - // to handle the case where the user grants the permission. See the documentation - // for ActivityCompat#requestPermissions for more details. - return; - } - - Notification notification = builder.build(); - NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification); - Log.i(TAG, "Posted notification. " + notification); - } - - private NotificationState constructNotificationState(@NonNull Context context, - @NonNull Cursor cursor) - { - NotificationState notificationState = new NotificationState(); - MmsSmsDatabase.Reader reader = DatabaseComponent.get(context).mmsSmsDatabase().readerFor(cursor); - ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); - - MessageRecord record; - Map cache = new HashMap(); - - while ((record = reader.getNext()) != null) { - long id = record.getId(); - boolean mms = record.isMms() || record.isMmsNotification(); - Recipient recipient = record.getIndividualRecipient(); - Recipient conversationRecipient = record.getRecipient(); - long threadId = record.getThreadId(); - CharSequence body = record.getDisplayBody(context); - Recipient threadRecipients = null; - SlideDeck slideDeck = null; - long timestamp = record.getTimestamp(); - boolean messageRequest = false; - - if (threadId != -1) { - threadRecipients = threadDatabase.getRecipientForThreadId(threadId); - messageRequest = threadRecipients != null && !threadRecipients.isGroupRecipient() && - !threadRecipients.isApproved() && !threadDatabase.getLastSeenAndHasSent(threadId).second(); - if (messageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !TextSecurePreferences.hasHiddenMessageRequests(context))) { - continue; - } - } - - // If this is a message request from an unknown user.. - if (messageRequest) { - body = SpanUtil.italic(context.getString(R.string.messageRequestsNew)); - - // If we received some manner of notification but Session is locked.. - } else if (KeyCachingService.isLocked(context)) { - // Note: We provide 1 because `messageNewYouveGot` is now a plurals string and we don't have a count yet, so just - // giving it 1 will result in "You got a new message". - body = SpanUtil.italic(context.getResources().getQuantityString(R.plurals.messageNewYouveGot, 1, 1)); - - // ----- All further cases assume we know the contact and that Session isn't locked ----- - - // If this is a notification about a multimedia message from a contact we know about.. - } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { - Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); - body = ContactUtil.getStringSummary(context, contact); - - // If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide.. - } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { - slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - body = SpanUtil.italic(slideDeck.getBody()); - - // If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide.. - } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { - slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - String message = slideDeck.getBody() + ": " + record.getBody(); - int italicLength = message.length() - body.length(); - body = SpanUtil.italic(message, italicLength); - - // If this is a notification about an invitation to a community.. - } else if (record.isOpenGroupInvitation()) { - body = SpanUtil.italic(context.getString(R.string.communityInvitation)); - } - - String userPublicKey = TextSecurePreferences.getLocalNumber(context); - String blindedPublicKey = cache.get(threadId); - if (blindedPublicKey == null) { - blindedPublicKey = generateBlindedId(threadId, context); - cache.put(threadId, blindedPublicKey); - } - if (threadRecipients == null || !threadRecipients.isMuted()) { - if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { - // check if mentioned here - boolean isQuoteMentioned = false; - if (record instanceof MmsMessageRecord) { - Quote quote = ((MmsMessageRecord) record).getQuote(); - Address quoteAddress = quote != null ? quote.getAuthor() : null; - String serializedAddress = quoteAddress != null ? quoteAddress.serialize() : null; - isQuoteMentioned = (serializedAddress!= null && Objects.equals(userPublicKey, serializedAddress)) || - (blindedPublicKey != null && Objects.equals(userPublicKey, blindedPublicKey)); - } - if (body.toString().contains("@"+userPublicKey) || body.toString().contains("@"+blindedPublicKey) || isQuoteMentioned) { - notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); - } - } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { - // do nothing, no notifications - } else { - notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); - } - - String userBlindedPublicKey = blindedPublicKey; - Optional lastReact = Stream.of(record.getReactions()) - .filter(r -> !(r.getAuthor().equals(userPublicKey) || r.getAuthor().equals(userBlindedPublicKey))) - .findLast(); - - if (lastReact.isPresent()) { - if (threadRecipients != null && !threadRecipients.isGroupRecipient()) { - ReactionRecord reaction = lastReact.get(); - Recipient reactor = Recipient.from(context, Address.fromSerialized(reaction.getAuthor()), false); - String emoji = Phrase.from(context, R.string.emojiReactsNotification).put(EMOJI_KEY, reaction.getEmoji()).format().toString(); - notificationState.addNotification(new NotificationItem(id, mms, reactor, reactor, threadRecipients, threadId, emoji, reaction.getDateSent(), slideDeck)); - } - } - } - } - - reader.close(); - return notificationState; - } - - private @Nullable String generateBlindedId(long threadId, Context context) { - LokiThreadDatabase lokiThreadDatabase = DatabaseComponent.get(context).lokiThreadDatabase(); - OpenGroup openGroup = lokiThreadDatabase.getOpenGroupChat(threadId); - KeyPair edKeyPair = KeyPairUtilities.INSTANCE.getUserED25519KeyPair(context); - if (openGroup != null && edKeyPair != null) { - KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair); - if (blindedKeyPair != null) { - return new AccountId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString(); - } - } - return null; - } - - private void updateBadge(Context context, int count) { - try { - if (count == 0) ShortcutBadger.removeCount(context); - else ShortcutBadger.applyCount(context, count); - } catch (Throwable t) { - // NOTE :: I don't totally trust this thing, so I'm catching - // everything. - Log.w("MessageNotifier", t); - } - } - - private void scheduleReminder(Context context, int count) { - if (count >= TextSecurePreferences.getRepeatAlertsCount(context)) { - return; - } - - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); - alarmIntent.putExtra("reminder_count", count); - - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); - long timeout = TimeUnit.MINUTES.toMillis(2); - - alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent); - } - - @Override - public void clearReminder(Context context) { - Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarmManager.cancel(pendingIntent); - } - - public static class ReminderReceiver extends BroadcastReceiver { - - public static final String REMINDER_ACTION = "network.loki.securesms.MessageNotifier.REMINDER_ACTION"; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, final Intent intent) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - int reminderCount = intent.getIntExtra("reminder_count", 0); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, true, reminderCount + 1); - - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } - - private static class DelayedNotification implements Runnable { - - private static final long DELAY = TimeUnit.SECONDS.toMillis(5); - - private final AtomicBoolean canceled = new AtomicBoolean(false); - - private final Context context; - private final long threadId; - private final long delayUntil; - - private DelayedNotification(Context context, long threadId) { - this.context = context; - this.threadId = threadId; - this.delayUntil = System.currentTimeMillis() + DELAY; - } - - @Override - public void run() { - long delayMillis = delayUntil - System.currentTimeMillis(); - Log.i(TAG, "Waiting to notify: " + delayMillis); - - if (delayMillis > 0) { - Util.sleep(delayMillis); - } - - if (!canceled.get()) { - Log.i(TAG, "Not canceled, notifying..."); - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId, true); - ApplicationContext.getInstance(context).messageNotifier.cancelDelayedNotifications(); - } else { - Log.w(TAG, "Canceled, not notifying..."); - } - } - - public void cancel() { - canceled.set(true); - } - } - - private static class CancelableExecutor { - - private final Executor executor = Executors.newSingleThreadExecutor(); - private final Set tasks = new HashSet<>(); - - public void execute(final DelayedNotification runnable) { - synchronized (tasks) { - tasks.add(runnable); - } - - Runnable wrapper = new Runnable() { - @Override - public void run() { - runnable.run(); - - synchronized (tasks) { - tasks.remove(runnable); - } - } - }; - - executor.execute(wrapper); - } - - public void cancel() { - synchronized (tasks) { - for (DelayedNotification task : tasks) { - task.cancel(); - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt new file mode 100644 index 0000000000..1a31ce01a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -0,0 +1,722 @@ +/* + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.os.AsyncTask +import android.os.Build +import android.text.TextUtils +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.annimon.stream.Stream +import com.squareup.phrase.Phrase +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.Volatile +import me.leolin.shortcutbadger.ShortcutBadger +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.utilities.AccountId +import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.ServiceUtil +import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy +import org.session.libsession.utilities.TextSecurePreferences.Companion.getRepeatAlertsCount +import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHiddenMessageRequests +import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled +import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Util +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.contacts.ContactUtil +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions +import org.thoughtcrime.securesms.crypto.KeyPairUtilities.getUserED25519KeyPair +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.SessionMetaProtocol.canUserReplyToNotification +import org.thoughtcrime.securesms.util.SpanUtil + +/** + * Handles posting system notifications for new messages. + * + * + * @author Moxie Marlinspike + */ +class DefaultMessageNotifier : MessageNotifier { + override fun setVisibleThread(threadId: Long) { + visibleThread = threadId + } + + override fun setHomeScreenVisible(isVisible: Boolean) { + homeScreenVisible = isVisible + } + + override fun setLastDesktopActivityTimestamp(timestamp: Long) { + lastDesktopActivityTimestamp = timestamp + } + + override fun notifyMessageDeliveryFailed(context: Context?, recipient: Recipient?, threadId: Long) { + // We do not provide notifications for message delivery failure. + } + + override fun cancelDelayedNotifications() { + executor.cancel() + } + + private fun cancelActiveNotifications(context: Context): Boolean { + val notifications = ServiceUtil.getNotificationManager(context) + val hasNotifications = notifications.activeNotifications.size > 0 + notifications.cancel(SUMMARY_NOTIFICATION_ID) + + try { + val activeNotifications = notifications.activeNotifications + + for (activeNotification in activeNotifications) { + notifications.cancel(activeNotification.id) + } + } catch (e: Throwable) { + // XXX Appears to be a ROM bug, see #6043 + Log.w(TAG, e) + notifications.cancelAll() + } + return hasNotifications + } + + private fun cancelOrphanedNotifications(context: Context, notificationState: NotificationState) { + try { + val notifications = ServiceUtil.getNotificationManager(context) + val activeNotifications = notifications.activeNotifications + + for (notification in activeNotifications) { + var validNotification = false + + if (notification.id != SUMMARY_NOTIFICATION_ID && notification.id != KeyCachingService.SERVICE_RUNNING_ID && notification.id != FOREGROUND_ID && notification.id != PENDING_MESSAGES_ID) { + for (item in notificationState.notifications) { + if (notification.id.toLong() == (SUMMARY_NOTIFICATION_ID + item.threadId)) { + validNotification = true + break + } + } + + if (!validNotification) { + notifications.cancel(notification.id) + } + } + } + } catch (e: Throwable) { + // XXX Android ROM Bug, see #6043 + Log.w(TAG, e) + } + } + + override fun updateNotification(context: Context) { + if (!isNotificationsEnabled(context)) { + return + } + + updateNotification(context, false, 0) + } + + override fun updateNotification(context: Context, threadId: Long) { + if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) { + Log.i(TAG, "Scheduling delayed notification...") + executor.execute(DelayedNotification(context, threadId)) + } else { + updateNotification(context, threadId, true) + } + } + + override fun updateNotification(context: Context, threadId: Long, signal: Boolean) { + val isVisible = visibleThread == threadId + + val threads = get(context).threadDatabase() + val recipient = threads.getRecipientForThreadId(threadId) + + if (recipient != null && !recipient.isGroupRecipient && threads.getMessageCount(threadId) == 1 && + !(recipient.isApproved || threads.getLastSeenAndHasSent(threadId).second()) + ) { + removeHasHiddenMessageRequests(context) + } + + if (!isNotificationsEnabled(context) || + (recipient != null && recipient.isMuted) + ) { + return + } + + if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { + updateNotification(context, signal, 0) + } + } + + private fun hasExistingNotifications(context: Context): Boolean { + val notifications = ServiceUtil.getNotificationManager(context) + try { + val activeNotifications = notifications.activeNotifications + return activeNotifications.isNotEmpty() + } catch (e: Exception) { + return false + } + } + + override fun updateNotification(context: Context, signal: Boolean, reminderCount: Int) { + var playNotificationAudio = signal // Local copy of the argument so we can modify it + var telcoCursor: Cursor? = null + val pushCursor: Cursor? = null + + try { + telcoCursor = get(context).mmsSmsDatabase().unread // TODO: add a notification specific lighter query here + + if ((telcoCursor == null || telcoCursor.isAfterLast) || getLocalNumber(context) == null) { + updateBadge(context, 0) + cancelActiveNotifications(context) + clearReminder(context) + return + } + + //var notificationState: NotificationState + try { + val notificationState = constructNotificationState(context, telcoCursor) + + if (playNotificationAudio && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) { + playNotificationAudio = false + } else if (playNotificationAudio) { + lastAudibleNotification = System.currentTimeMillis() + } + + if (notificationState.hasMultipleThreads()) { + for (threadId in notificationState.threads) { + sendSingleThreadNotification(context, NotificationState(notificationState.getNotificationsForThread(threadId)), false, true) + } + sendMultipleThreadNotification(context, notificationState, playNotificationAudio) + } else if (notificationState.messageCount > 0) { + sendSingleThreadNotification(context, notificationState, playNotificationAudio, false) + } else { + cancelActiveNotifications(context) + } + + cancelOrphanedNotifications(context, notificationState) + updateBadge(context, notificationState.messageCount) + + if (playNotificationAudio) { + scheduleReminder(context, reminderCount) + } + } + catch (e: Exception) { + Log.e(TAG, "Error creating notification", e) + } + + } finally { + telcoCursor?.close() + } + } + + // Note: The `signal` parameter means "play an audio signal for the notification". + private fun sendSingleThreadNotification( + context: Context, + notificationState: NotificationState, + signal: Boolean, + bundled: Boolean + ) { + Log.i(TAG, "sendSingleThreadNotification() signal: $signal bundled: $bundled") + + if (notificationState.notifications.isEmpty()) { + if (!bundled) cancelActiveNotifications(context) + Log.i(TAG, "Empty notification state. Skipping.") + return + } + + val builder = SingleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + val notifications = notificationState.notifications + val recipient = notifications[0].recipient + val notificationId = (SUMMARY_NOTIFICATION_ID + (if (bundled) notifications[0].threadId else 0)).toInt() + val messageIdTag = notifications[0].timestamp.toString() + + val notificationManager = ServiceUtil.getNotificationManager(context) + for (notification in notificationManager.activeNotifications) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && notification.isAppGroup == bundled) + && (messageIdTag == notification.notification.extras.getString(LATEST_MESSAGE_ID_TAG)) + ) { + return + } + } + + val timestamp = notifications[0].timestamp + if (timestamp != 0L) builder.setWhen(timestamp) + + builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag) + + val text = notifications[0].text + + builder.setThread(notifications[0].recipient) + builder.setMessageCount(notificationState.messageCount) + + val builderCS = text ?: "" + val ss = highlightMentions( + builderCS, + false, + false, + true, + if (bundled) notifications[0].threadId else 0, + context + ) + + builder.setPrimaryMessageBody( + recipient, + notifications[0].individualRecipient, + ss, + notifications[0].slideDeck + ) + + builder.setContentIntent(notifications[0].getPendingIntent(context)) + builder.setDeleteIntent(notificationState.getDeleteIntent(context)) + builder.setOnlyAlertOnce(!signal) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.setAutoCancel(true) + + val replyMethod = ReplyMethod.forRecipient(context, recipient) + + val canReply = canUserReplyToNotification(recipient) + + val quickReplyIntent = if (canReply) notificationState.getQuickReplyIntent(context, recipient) else null + val remoteReplyIntent = if (canReply) notificationState.getRemoteReplyIntent(context, recipient, replyMethod) else null + + builder.addActions( + notificationState.getMarkAsReadIntent(context, notificationId), + quickReplyIntent, + remoteReplyIntent, + replyMethod + ) + + if (canReply) { + builder.addAndroidAutoAction( + notificationState.getAndroidAutoReplyIntent(context, recipient), + notificationState.getAndroidAutoHeardIntent(context, notificationId), + notifications[0].timestamp + ) + } + + val iterator: ListIterator = notifications.listIterator(notifications.size) + + while (iterator.hasPrevious()) { + val item = iterator.previous() + builder.addMessageBody(item.recipient, item.individualRecipient, item.text) + } + + if (signal) { + builder.setAlarms(notificationState.getRingtone(context), notificationState.vibrate) + builder.setTicker( + notifications[0].individualRecipient, + notifications[0].text + ) + } + + if (bundled) { + builder.setGroup(NOTIFICATION_GROUP) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + } + + val notification = builder.build() + + // ACL FIX THIS PROPERLY + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + NotificationManagerCompat.from(context).notify(notificationId, notification) + Log.i(TAG, "Posted notification. $notification") + } + + // Note: The `signal` parameter means "play an audio signal for the notification". + private fun sendMultipleThreadNotification( + context: Context, + notificationState: NotificationState, + signal: Boolean + ) { + Log.i(TAG, "sendMultiThreadNotification() signal: $signal") + + val builder = MultipleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + val notifications = notificationState.notifications + + builder.setMessageCount(notificationState.messageCount, notificationState.threadCount) + builder.setMostRecentSender(notifications[0].individualRecipient, notifications[0].recipient) + builder.setGroup(NOTIFICATION_GROUP) + builder.setDeleteIntent(notificationState.getDeleteIntent(context)) + builder.setOnlyAlertOnce(!signal) + builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + builder.setAutoCancel(true) + + val messageIdTag = notifications[0].timestamp.toString() + + val notificationManager = ServiceUtil.getNotificationManager(context) + for (notification in notificationManager.activeNotifications) { + if (notification.id == SUMMARY_NOTIFICATION_ID && messageIdTag == notification.notification.extras.getString(LATEST_MESSAGE_ID_TAG)) { + return + } + } + + val timestamp = notifications[0].timestamp + if (timestamp != 0L) builder.setWhen(timestamp) + + builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID)) + + val iterator: ListIterator = notifications.listIterator(notifications.size) + while (iterator.hasPrevious()) { + val item = iterator.previous() + builder.addMessageBody( + item.individualRecipient, item.recipient, + highlightMentions( + (if (item.text != null) item.text else "")!!, + false, + false, + true, // no styling here, only text formatting + item.threadId, + context + ) + ) + } + + if (signal) { + builder.setAlarms(notificationState.getRingtone(context), notificationState.vibrate) + val text = notifications[0].text + builder.setTicker( + notifications[0].individualRecipient, + highlightMentions( + text ?: "", + false, + false, + true, // no styling here, only text formatting + notifications[0].threadId, + context + ) + ) + } + + builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag) + + // ACL FIX THIS PROPERLY + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + + val notification = builder.build() + NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, notification) + Log.i(TAG, "Posted notification. $notification") + } + + private fun constructNotificationState(context: Context, cursor: Cursor): NotificationState { + val notificationState = NotificationState() + val reader = get(context).mmsSmsDatabase().readerFor(cursor) + if (reader == null) { + Log.e(TAG, "No reader for cursor - aborting constructNotificationState") + return NotificationState() + } + + val threadDatabase = get(context).threadDatabase() + val cache: MutableMap = HashMap() + + // CAREFUL: Do not put this loop back as `while ((reader.next.also { record = it }) != null) {` because it breaks with a Null Pointer Exception - you have been warned. + var record: MessageRecord? = null + do { + record = reader.next + if (record == null) break // Bail if there are no more MessageRecords + + val id = record.getId() + val mms = record.isMms || record.isMmsNotification + val recipient = record.individualRecipient + val conversationRecipient = record.recipient + val threadId = record.threadId + var body: CharSequence = record.getDisplayBody(context) + var threadRecipients: Recipient? = null + var slideDeck: SlideDeck? = null + val timestamp = record.timestamp + var messageRequest = false + + if (threadId != -1L) { + threadRecipients = threadDatabase.getRecipientForThreadId(threadId) + messageRequest = threadRecipients != null && !threadRecipients.isGroupRecipient && + !threadRecipients.isApproved && !threadDatabase.getLastSeenAndHasSent(threadId).second() + if (messageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !hasHiddenMessageRequests(context))) { + continue + } + } + + // If this is a message request from an unknown user.. + if (messageRequest) { + body = SpanUtil.italic(context.getString(R.string.messageRequestsNew)) + + // If we received some manner of notification but Session is locked.. + } else if (KeyCachingService.isLocked(context)) { + // Note: We provide 1 because `messageNewYouveGot` is now a plurals string and we don't have a count yet, so just + // giving it 1 will result in "You got a new message". + body = SpanUtil.italic(context.resources.getQuantityString(R.plurals.messageNewYouveGot, 1, 1)) + + // ----- Note: All further cases assume we know the contact and that Session isn't locked ----- + + // If this is a notification about a multimedia message from a contact we know about.. + } else if (record.isMms && !(record as MmsMessageRecord).sharedContacts.isEmpty()) { + val contact = (record as MmsMessageRecord).sharedContacts[0] + body = ContactUtil.getStringSummary(context, contact) + + // If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide.. + } else if (record.isMms && TextUtils.isEmpty(body) && !(record as MmsMessageRecord).slideDeck.slides.isEmpty()) { + slideDeck = (record as MediaMmsMessageRecord).slideDeck + body = SpanUtil.italic(slideDeck.body) + + // If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide.. + } else if (record.isMms && !record.isMmsNotification && !(record as MmsMessageRecord).slideDeck.slides.isEmpty()) { + slideDeck = (record as MediaMmsMessageRecord).slideDeck + val message = slideDeck.body + ": " + record.body + val italicLength = message.length - body.length + body = SpanUtil.italic(message, italicLength) + + // If this is a notification about an invitation to a community.. + } else if (record.isOpenGroupInvitation) { + body = SpanUtil.italic(context.getString(R.string.communityInvitation)) + } + + val userPublicKey = getLocalNumber(context) + var blindedPublicKey = cache[threadId] + if (blindedPublicKey == null) { + blindedPublicKey = generateBlindedId(threadId, context) + cache[threadId] = blindedPublicKey + } + if (threadRecipients == null || !threadRecipients.isMuted) { + if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { + // check if mentioned here + var isQuoteMentioned = false + if (record is MmsMessageRecord) { + val quote = (record as MmsMessageRecord).quote + val quoteAddress = quote?.author + val serializedAddress = quoteAddress?.serialize() + isQuoteMentioned = (serializedAddress != null && userPublicKey == serializedAddress) || + (blindedPublicKey != null && userPublicKey == blindedPublicKey) + } + if (body.toString().contains("@$userPublicKey") || body.toString().contains("@$blindedPublicKey") || isQuoteMentioned) { + notificationState.addNotification(NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)) + } + } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { + // do nothing, no notifications + } else { + notificationState.addNotification(NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)) + } + + val userBlindedPublicKey = blindedPublicKey + val lastReact = Stream.of(record.reactions) + .filter { r: ReactionRecord -> !(r.author == userPublicKey || r.author == userBlindedPublicKey) } + .findLast() + + if (lastReact.isPresent) { + if (threadRecipients != null && !threadRecipients.isGroupRecipient) { + val reaction = lastReact.get() + val reactor = Recipient.from(context, fromSerialized(reaction.author), false) + val emoji = Phrase.from(context, R.string.emojiReactsNotification).put(EMOJI_KEY, reaction.emoji).format().toString() + notificationState.addNotification(NotificationItem(id, mms, reactor, reactor, threadRecipients, threadId, emoji, reaction.dateSent, slideDeck)) + } + } + } + } while (record != null) // This will never hit because we break early if we get a null record at the start of the do..while loop + + reader.close() + return notificationState + } + + private fun generateBlindedId(threadId: Long, context: Context): String? { + val lokiThreadDatabase = get(context).lokiThreadDatabase() + val openGroup = lokiThreadDatabase.getOpenGroupChat(threadId) + val edKeyPair = getUserED25519KeyPair(context) + if (openGroup != null && edKeyPair != null) { + val blindedKeyPair = blindedKeyPair(openGroup.publicKey, edKeyPair) + if (blindedKeyPair != null) { + return AccountId(IdPrefix.BLINDED, blindedKeyPair.publicKey.asBytes).hexString + } + } + return null + } + + private fun updateBadge(context: Context, count: Int) { + try { + if (count == 0) ShortcutBadger.removeCount(context) + else ShortcutBadger.applyCount(context, count) + } catch (t: Throwable) { + // NOTE :: I don't totally trust this thing, so I'm catching everything. + Log.w("MessageNotifier", t) + } + } + + private fun scheduleReminder(context: Context, count: Int) { + if (count >= getRepeatAlertsCount(context)) { + return + } + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val alarmIntent = Intent(ReminderReceiver.REMINDER_ACTION) + alarmIntent.putExtra("reminder_count", count) + + val pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val timeout = TimeUnit.MINUTES.toMillis(2) + + alarmManager[AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout] = pendingIntent + } + + override fun clearReminder(context: Context) { + val alarmIntent = Intent(ReminderReceiver.REMINDER_ACTION) + val pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pendingIntent) + } + + class ReminderReceiver : BroadcastReceiver() { + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + object : AsyncTask() { + + override fun doInBackground(vararg params: Void?): Void? { + val reminderCount = intent.getIntExtra("reminder_count", 0) + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, true, reminderCount + 1) + return null + } + + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + companion object { + const val REMINDER_ACTION: String = "network.loki.securesms.MessageNotifier.REMINDER_ACTION" + } + } + + // ACL: What is the concept behind delayed notifications? Why would we ever want this? To batch them up so + // that we get a bunch of notifications once per minute or something rather than a constant stream of them + // if that's what was incoming?!? + private class DelayedNotification(private val context: Context, private val threadId: Long) : Runnable { + private val canceled = AtomicBoolean(false) + + private val delayUntil: Long + + init { + this.delayUntil = System.currentTimeMillis() + DELAY + } + + override fun run() { + val delayMillis = delayUntil - System.currentTimeMillis() + Log.i(TAG, "Waiting to notify: $delayMillis") + + if (delayMillis > 0) { Util.sleep(delayMillis) } + + if (!canceled.get()) { + Log.i(TAG, "Not canceled, notifying...") + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId, true) + ApplicationContext.getInstance(context).messageNotifier.cancelDelayedNotifications() + } else { + Log.w(TAG, "Canceled, not notifying...") + } + } + + fun cancel() { + canceled.set(true) + } + + companion object { + private val DELAY = TimeUnit.SECONDS.toMillis(5) + } + } + + private class CancelableExecutor { + private val executor: Executor = Executors.newSingleThreadExecutor() + private val tasks: MutableSet = HashSet() + + fun execute(runnable: DelayedNotification) { + synchronized(tasks) { tasks.add(runnable) } + + val wrapper = Runnable { + runnable.run() + synchronized(tasks) { + tasks.remove(runnable) + } + } + + executor.execute(wrapper) + } + + fun cancel() { + synchronized(tasks) { + for (task in tasks) { task.cancel() } + } + } + } + + companion object { + private val TAG: String = DefaultMessageNotifier::class.java.simpleName + + const val EXTRA_REMOTE_REPLY: String = "extra_remote_reply" + const val LATEST_MESSAGE_ID_TAG: String = "extra_latest_message_id" + + private const val FOREGROUND_ID = 313399 + private const val SUMMARY_NOTIFICATION_ID = 1338 + private const val PENDING_MESSAGES_ID = 1111 + private const val NOTIFICATION_GROUP = "messages" + private val MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(5) + private val DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1) + + @Volatile + private var visibleThread: Long = -1 + + @Volatile + private var homeScreenVisible = false + + @Volatile + private var lastDesktopActivityTimestamp: Long = -1 + + @Volatile + private var lastAudibleNotification: Long = -1 + private val executor = CancelableExecutor() + } +}