Missed call notifications

Dmitry Markin created

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java           |   4 
src/main/java/eu/siacs/conversations/services/NotificationService.java    | 239 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java  |  31 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java |   1 
src/main/res/values/strings.xml                                           |   5 
5 files changed, 261 insertions(+), 19 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Conversation.java ๐Ÿ”—

@@ -227,11 +227,11 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         }
     }
 
-    public void findUnreadMessages(OnMessageFound onMessageFound) {
+    public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
         final ArrayList<Message> results = new ArrayList<>();
         synchronized (this.messages) {
             for (final Message message : this.messages) {
-                if (message.isRead() || message.getType() == Message.TYPE_RTP_SESSION) {
+                if (message.isRead()) {
                     continue;
                 }
                 results.add(message);

src/main/java/eu/siacs/conversations/services/NotificationService.java ๐Ÿ”—

@@ -75,16 +75,19 @@ public class NotificationService {
     private static final int CALL_DAT = 120;
     private static final long[] CALL_PATTERN = {0, 3 * CALL_DAT, CALL_DAT, CALL_DAT, 3 * CALL_DAT, CALL_DAT, CALL_DAT};
 
-    private static final String CONVERSATIONS_GROUP = "eu.siacs.conversations";
+    private static final String MESSAGES_GROUP = "eu.siacs.conversations.messages";
+    private static final String MISSED_CALLS_GROUP = "eu.siacs.conversations.missed_calls";
     private static final int NOTIFICATION_ID_MULTIPLIER = 1024 * 1024;
     static final int FOREGROUND_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 4;
     private static final int NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 2;
     private static final int ERROR_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 6;
     private static final int INCOMING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 8;
     public static final int ONGOING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 10;
+    public static final int MISSED_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 12;
     private final XmppConnectionService mXmppConnectionService;
     private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
     private final HashMap<Conversation, AtomicInteger> mBacklogMessageCounter = new HashMap<>();
+    private final LinkedHashMap<Conversational, MissedCallsInfo> mMissedCalls = new LinkedHashMap<>();
     private Conversation mOpenConversation;
     private boolean mIsInForeground;
     private long mLastNotification;
@@ -182,6 +185,15 @@ public class NotificationService {
         ongoingCallsChannel.setGroup("calls");
         notificationManager.createNotificationChannel(ongoingCallsChannel);
 
+        final NotificationChannel missedCallsChannel = new NotificationChannel("missed_calls",
+                c.getString(R.string.missed_calls_channel_name),
+                NotificationManager.IMPORTANCE_HIGH);
+        missedCallsChannel.setShowBadge(true);
+        missedCallsChannel.setSound(null, null);
+        missedCallsChannel.setLightColor(LED_COLOR);
+        missedCallsChannel.enableLights(true);
+        missedCallsChannel.setGroup("calls");
+        notificationManager.createNotificationChannel(missedCallsChannel);
 
         final NotificationChannel messagesChannel = new NotificationChannel("messages",
                 c.getString(R.string.messages_channel_name),
@@ -222,12 +234,18 @@ public class NotificationService {
         notificationManager.createNotificationChannel(quietHoursChannel);
     }
 
-    public boolean notify(final Message message) {
+    private boolean notifyMessage(final Message message) {
         final Conversation conversation = (Conversation) message.getConversation();
         return message.getStatus() == Message.STATUS_RECEIVED
                 && !conversation.isMuted()
                 && (conversation.alwaysNotify() || wasHighlightedOrPrivate(message))
-                && (!conversation.isWithStranger() || notificationsFromStrangers());
+                && (!conversation.isWithStranger() || notificationsFromStrangers())
+                && message.getType() != Message.TYPE_RTP_SESSION;
+    }
+
+    private boolean notifyMissedCall(final Message message) {
+        return message.getType() == Message.TYPE_RTP_SESSION
+                && message.getStatus() == Message.STATUS_RECEIVED;
     }
 
     public boolean notificationsFromStrangers() {
@@ -251,11 +269,15 @@ public class NotificationService {
     }
 
     public void pushFromBacklog(final Message message) {
-        if (notify(message)) {
+        if (notifyMessage(message)) {
             synchronized (notifications) {
                 getBacklogMessageCounter((Conversation) message.getConversation()).incrementAndGet();
                 pushToStack(message);
             }
+        } else if (notifyMissedCall(message)) {
+            synchronized (mMissedCalls) {
+                pushMissedCall(message);
+            }
         }
     }
 
@@ -290,6 +312,9 @@ public class NotificationService {
                 updateNotification(count > 0, conversations);
             }
         }
+        synchronized (mMissedCalls) {
+            updateMissedCallNotifications(mMissedCalls.keySet());
+        }
     }
 
     private List<String> getBacklogConversations(Account account) {
@@ -437,7 +462,7 @@ public class NotificationService {
 
     private void pushNow(final Message message) {
         mXmppConnectionService.updateUnreadCountBadge();
-        if (!notify(message)) {
+        if (!notifyMessage(message)) {
             Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": suppressing notification because turned off");
             return;
         }
@@ -457,7 +482,29 @@ public class NotificationService {
         }
     }
 
-    public void clear() {
+    private void pushMissedCall(final Message message) {
+        final Conversational conversation = message.getConversation();
+        final MissedCallsInfo info = mMissedCalls.get(conversation);
+        if (info == null) {
+            mMissedCalls.put(conversation, new MissedCallsInfo(message.getTimeSent()));
+        } else {
+            info.newMissedCall(message.getTimeSent());
+        }
+    }
+
+    public void pushMissedCallNow(final Message message) {
+        synchronized (mMissedCalls) {
+            pushMissedCall(message);
+            updateMissedCallNotifications(Collections.singleton(message.getConversation()));
+        }
+    }
+
+    public void clear(final Conversation conversation) {
+        clearMessages(conversation);
+        clearMissedCalls(conversation);
+    }
+
+    public void clearMessages() {
         synchronized (notifications) {
             for (ArrayList<Message> messages : notifications.values()) {
                 markAsReadIfHasDirectReply(messages);
@@ -467,7 +514,7 @@ public class NotificationService {
         }
     }
 
-    public void clear(final Conversation conversation) {
+    public void clearMessages(final Conversation conversation) {
         synchronized (this.mBacklogMessageCounter) {
             this.mBacklogMessageCounter.remove(conversation);
         }
@@ -480,6 +527,25 @@ public class NotificationService {
         }
     }
 
+    public void clearMissedCalls() {
+        synchronized (mMissedCalls) {
+            for (final Conversational conversation : mMissedCalls.keySet()) {
+                cancel(conversation.getUuid(), MISSED_CALL_NOTIFICATION_ID);
+            }
+            mMissedCalls.clear();
+            updateMissedCallNotifications(null);
+        }
+    }
+
+    public void clearMissedCalls(final Conversation conversation) {
+        synchronized (mMissedCalls) {
+            if (mMissedCalls.remove(conversation) != null) {
+                cancel(conversation.getUuid(), MISSED_CALL_NOTIFICATION_ID);
+                updateMissedCallNotifications(null);
+            }
+        }
+    }
+
     private void markAsReadIfHasDirectReply(final Conversation conversation) {
         markAsReadIfHasDirectReply(notifications.get(conversation.getUuid()));
     }
@@ -547,7 +613,7 @@ public class NotificationService {
                             singleBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
                         }
                         modifyForSoundVibrationAndLight(singleBuilder, notifyThis, quiteHours, preferences);
-                        singleBuilder.setGroup(CONVERSATIONS_GROUP);
+                        singleBuilder.setGroup(MESSAGES_GROUP);
                         setNotificationColor(singleBuilder);
                         notify(entry.getKey(), NOTIFICATION_ID, singleBuilder.build());
                     }
@@ -557,6 +623,31 @@ public class NotificationService {
         }
     }
 
+    private void updateMissedCallNotifications(final Set<Conversational> update) {
+        if (mMissedCalls.isEmpty()) {
+            cancel(MISSED_CALL_NOTIFICATION_ID);
+            return;
+        }
+        if (mMissedCalls.size() == 1 && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+            final Conversational conversation = mMissedCalls.keySet().iterator().next();
+            final MissedCallsInfo info = mMissedCalls.values().iterator().next();
+            final Notification notification = missedCall(conversation, info);
+            notify(MISSED_CALL_NOTIFICATION_ID, notification);
+        } else {
+            final Notification summary = missedCallsSummary();
+            notify(MISSED_CALL_NOTIFICATION_ID, summary);
+            if (update != null) {
+                for (final Conversational conversation : update) {
+                    final MissedCallsInfo info = mMissedCalls.get(conversation);
+                    if (info != null) {
+                        final Notification notification = missedCall(conversation, info);
+                        notify(conversation.getUuid(), MISSED_CALL_NOTIFICATION_ID, notification);
+                    }
+                }
+            }
+        }
+    }
+
     private void modifyForSoundVibrationAndLight(Builder mBuilder, boolean notify, boolean quietHours, SharedPreferences preferences) {
         final Resources resources = mXmppConnectionService.getResources();
         final String ringtone = preferences.getString("notification_ringtone", resources.getString(R.string.notification_ringtone));
@@ -615,6 +706,101 @@ public class NotificationService {
         }
     }
 
+    private Notification missedCallsSummary() {
+        final Builder publicBuilder = buildMissedCallsSummary(true);
+        final Builder builder = buildMissedCallsSummary(false);
+        builder.setPublicVersion(publicBuilder.build());
+        return builder.build();
+    }
+
+    private Builder buildMissedCallsSummary(boolean publicVersion) {
+        final Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
+        int totalCalls = 0;
+        final StringBuilder names = new StringBuilder();
+        long lastTime = 0;
+        for (Map.Entry<Conversational, MissedCallsInfo> entry : mMissedCalls.entrySet()) {
+            final Conversational conversation = entry.getKey();
+            final MissedCallsInfo missedCallsInfo = entry.getValue();
+            names.append(conversation.getContact().getDisplayName());
+            names.append(", ");
+            totalCalls += missedCallsInfo.getNumberOfCalls();
+            lastTime = Math.max(lastTime, missedCallsInfo.getLastTime());
+        }
+        if (names.length() >= 2) {
+            names.delete(names.length() - 2, names.length());
+        }
+        final String title = (totalCalls == 1) ? mXmppConnectionService.getString(R.string.missed_call) :
+                             (mMissedCalls.size() == 1) ? mXmppConnectionService.getString(R.string.n_missed_calls, totalCalls) :
+                             mXmppConnectionService.getString(R.string.n_missed_calls_from_m_contacts, totalCalls, mMissedCalls.size());
+        builder.setContentTitle(title);
+        builder.setTicker(title);
+        if (!publicVersion) {
+            builder.setContentText(names.toString());
+        }
+        builder.setSmallIcon(R.drawable.ic_notification);
+        builder.setGroupSummary(true);
+        builder.setGroup(MISSED_CALLS_GROUP);
+        builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN);
+        builder.setCategory(NotificationCompat.CATEGORY_CALL);
+        builder.setWhen(lastTime);
+        if (!mMissedCalls.isEmpty()) {
+            final Conversational firstConversation = mMissedCalls.keySet().iterator().next();
+            builder.setContentIntent(createContentIntent(firstConversation));
+        }
+        builder.setDeleteIntent(createMissedCallsDeleteIntent(null));
+        modifyMissedCall(builder);
+        return builder;
+    }
+
+    private Notification missedCall(final Conversational conversation, final MissedCallsInfo info) {
+        final Builder publicBuilder = buildMissedCall(conversation, info, true);
+        final Builder builder = buildMissedCall(conversation, info, false);
+        builder.setPublicVersion(publicBuilder.build());
+        return builder.build();
+    }
+
+    private Builder buildMissedCall(final Conversational conversation, final MissedCallsInfo info, boolean publicVersion) {
+        final Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
+        final String title = (info.getNumberOfCalls() == 1) ? mXmppConnectionService.getString(R.string.missed_call) :
+                                                              mXmppConnectionService.getString(R.string.n_missed_calls, info.getNumberOfCalls());
+        builder.setContentTitle(title);
+        final String name = conversation.getContact().getDisplayName();
+        if (publicVersion) {
+            builder.setTicker(title);
+        } else {
+            if (info.getNumberOfCalls() == 1) {
+                builder.setTicker(mXmppConnectionService.getString(R.string.missed_call_from_x, name));
+            } else {
+                builder.setTicker(mXmppConnectionService.getString(R.string.n_missed_calls_from_x, info.getNumberOfCalls(), name));
+            }
+            builder.setContentText(name);
+        }
+        builder.setSmallIcon(R.drawable.ic_notification);
+        builder.setGroup(MISSED_CALLS_GROUP);
+        builder.setCategory(NotificationCompat.CATEGORY_CALL);
+        builder.setWhen(info.getLastTime());
+        builder.setContentIntent(createContentIntent(conversation));
+        builder.setDeleteIntent(createMissedCallsDeleteIntent(conversation));
+        if (!publicVersion && conversation instanceof Conversation) {
+            builder.setLargeIcon(mXmppConnectionService.getAvatarService()
+                    .get((Conversation) conversation, AvatarService.getSystemUiAvatarSize(mXmppConnectionService)));
+        }
+        modifyMissedCall(builder);
+        return builder;
+    }
+
+    private void modifyMissedCall(final Builder builder) {
+        final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mXmppConnectionService);
+        final Resources resources = mXmppConnectionService.getResources();
+        final boolean led = preferences.getBoolean("led", resources.getBoolean(R.bool.led));
+        if (led) {
+            builder.setLights(LED_COLOR, 2000, 3000);
+        }
+        builder.setPriority(NotificationCompat.PRIORITY_HIGH);
+        builder.setSound(null);
+        setNotificationColor(builder);
+    }
+
     private Builder buildMultipleConversation(final boolean notify, final boolean quietHours) {
         final Builder mBuilder = new NotificationCompat.Builder(mXmppConnectionService, quietHours ? "quiet_hours" : (notify ? "messages" : "silent_messages"));
         final NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle();
@@ -651,7 +837,7 @@ public class NotificationService {
             mBuilder.setContentIntent(createContentIntent(conversation));
         }
         mBuilder.setGroupSummary(true);
-        mBuilder.setGroup(CONVERSATIONS_GROUP);
+        mBuilder.setGroup(MESSAGES_GROUP);
         mBuilder.setDeleteIntent(createDeleteIntent(null));
         mBuilder.setSmallIcon(R.drawable.ic_notification);
         return mBuilder;
@@ -952,7 +1138,7 @@ public class NotificationService {
 
     private PendingIntent createDeleteIntent(Conversation conversation) {
         final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
-        intent.setAction(XmppConnectionService.ACTION_CLEAR_NOTIFICATION);
+        intent.setAction(XmppConnectionService.ACTION_CLEAR_MESSAGE_NOTIFICATION);
         if (conversation != null) {
             intent.putExtra("uuid", conversation.getUuid());
             return PendingIntent.getService(mXmppConnectionService, generateRequestCode(conversation, 20), intent, 0);
@@ -960,6 +1146,16 @@ public class NotificationService {
         return PendingIntent.getService(mXmppConnectionService, 0, intent, 0);
     }
 
+    private PendingIntent createMissedCallsDeleteIntent(final Conversational conversation) {
+        final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
+        intent.setAction(XmppConnectionService.ACTION_CLEAR_MISSED_CALL_NOTIFICATION);
+        if (conversation != null) {
+            intent.putExtra("uuid", conversation.getUuid());
+            return PendingIntent.getService(mXmppConnectionService, generateRequestCode(conversation, 21), intent, 0);
+        }
+        return PendingIntent.getService(mXmppConnectionService, 1, intent, 0);
+    }
+
     private PendingIntent createReplyIntent(Conversation conversation, boolean dismissAfterReply) {
         final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
         intent.setAction(XmppConnectionService.ACTION_REPLY_TO_CONVERSATION);
@@ -1189,4 +1385,27 @@ public class NotificationService {
             Log.d(Config.LOGTAG, "unable to cancel notification", e);
         }
     }
+
+    private static class MissedCallsInfo {
+        private int numberOfCalls;
+        private long lastTime;
+
+        MissedCallsInfo(final long time) {
+            numberOfCalls = 1;
+            lastTime = time;
+        }
+
+        public void newMissedCall(final long time) {
+            ++numberOfCalls;
+            lastTime = time;
+        }
+
+        public int getNumberOfCalls() {
+            return numberOfCalls;
+        }
+
+        public long getLastTime() {
+            return lastTime;
+        }
+    }
 }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java ๐Ÿ”—

@@ -162,7 +162,8 @@ public class XmppConnectionService extends Service {
     public static final String ACTION_REPLY_TO_CONVERSATION = "reply_to_conversations";
     public static final String ACTION_MARK_AS_READ = "mark_as_read";
     public static final String ACTION_SNOOZE = "snooze";
-    public static final String ACTION_CLEAR_NOTIFICATION = "clear_notification";
+    public static final String ACTION_CLEAR_MESSAGE_NOTIFICATION = "clear_message_notification";
+    public static final String ACTION_CLEAR_MISSED_CALL_NOTIFICATION = "clear_missed_call_notification";
     public static final String ACTION_DISMISS_ERROR_NOTIFICATIONS = "dismiss_error";
     public static final String ACTION_TRY_AGAIN = "try_again";
     public static final String ACTION_IDLE_PING = "idle_ping";
@@ -632,19 +633,35 @@ public class XmppConnectionService extends Service {
                 case Intent.ACTION_SHUTDOWN:
                     logoutAndSave(true);
                     return START_NOT_STICKY;
-                case ACTION_CLEAR_NOTIFICATION:
+                case ACTION_CLEAR_MESSAGE_NOTIFICATION:
                     mNotificationExecutor.execute(() -> {
                         try {
                             final Conversation c = findConversationByUuid(uuid);
                             if (c != null) {
-                                mNotificationService.clear(c);
+                                mNotificationService.clearMessages(c);
                             } else {
-                                mNotificationService.clear();
+                                mNotificationService.clearMessages();
                             }
                             restoredFromDatabaseLatch.await();
 
                         } catch (InterruptedException e) {
-                            Log.d(Config.LOGTAG, "unable to process clear notification");
+                            Log.d(Config.LOGTAG, "unable to process clear message notification");
+                        }
+                    });
+                    break;
+                case ACTION_CLEAR_MISSED_CALL_NOTIFICATION:
+                    mNotificationExecutor.execute(() -> {
+                        try {
+                            final Conversation c = findConversationByUuid(uuid);
+                            if (c != null) {
+                                mNotificationService.clearMissedCalls(c);
+                            } else {
+                                mNotificationService.clearMissedCalls();
+                            }
+                            restoredFromDatabaseLatch.await();
+
+                        } catch (InterruptedException e) {
+                            Log.d(Config.LOGTAG, "unable to process clear missed call notification");
                         }
                     });
                     break;
@@ -722,7 +739,7 @@ public class XmppConnectionService extends Service {
                             return;
                         }
                         c.setMutedTill(System.currentTimeMillis() + 30 * 60 * 1000);
-                        mNotificationService.clear(c);
+                        mNotificationService.clearMessages(c);
                         updateConversation(c);
                     });
                 case AudioManager.RINGER_MODE_CHANGED_ACTION:
@@ -1819,7 +1836,7 @@ public class XmppConnectionService extends Service {
     private void restoreMessages(Conversation conversation) {
         conversation.addAll(0, databaseBackend.getMessages(conversation, Config.PAGE_SIZE));
         conversation.findUnsentTextMessages(message -> markMessage(message, Message.STATUS_WAITING));
-        conversation.findUnreadMessages(message -> mNotificationService.pushFromBacklog(message));
+        conversation.findUnreadMessagesAndCalls(message -> mNotificationService.pushFromBacklog(message));
     }
 
     public void loadPhoneContacts() {

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java ๐Ÿ”—

@@ -627,6 +627,7 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
             final State target = this.state == State.PROCEED ? State.RETRACTED_RACED : State.RETRACTED;
             if (transition(target)) {
                 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+                xmppConnectionService.getNotificationService().pushMissedCallNow(message);
                 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted (serverMsgId=" + serverMsgId + ")");
                 if (serverMsgId != null) {
                     this.message.setServerMsgId(serverMsgId);

src/main/res/values/strings.xml ๐Ÿ”—

@@ -754,6 +754,7 @@
     <string name="messages_channel_name">Messages</string>
     <string name="incoming_calls_channel_name">Incoming calls</string>
     <string name="ongoing_calls_channel_name">Ongoing calls</string>
+    <string name="missed_calls_channel_name">Missed calls</string>
     <string name="silent_messages_channel_name">Silent messages</string>
     <string name="silent_messages_channel_description">This notification group is used to display notifications that should not trigger any sound. For example when being active on another device (Grace Period).</string>
     <string name="pref_message_notification_settings">Message notification settings</string>
@@ -915,6 +916,10 @@
     <string name="outgoing_call">Outgoing call</string>
     <string name="outgoing_call_duration">Outgoing call ยท %s</string>
     <string name="missed_call">Missed call</string>
+    <string name="missed_call_from_x">Missed call from %s</string>
+    <string name="n_missed_calls_from_x">%1$d missed calls from %2$s</string>
+    <string name="n_missed_calls">%d missed calls</string>
+    <string name="n_missed_calls_from_m_contacts">%1$d missed calls from %2$d contacts</string>
     <string name="audio_call">Audio call</string>
     <string name="video_call">Video call</string>
     <string name="help">Help</string>