Merge branch 'feature/missed_call_notifications' of https://github.com/dmitry-markin/Conversations

Stephen Paul Weber created

* 'feature/missed_call_notifications' of https://github.com/dmitry-markin/Conversations:
  Fix: show missed call notification if ringing timeout is reached
  Add missed call notification icon
  Missed call notifications

Change summary

art/ic_missed_call_notification.svg                                       | 344 
art/render.rb                                                             |   3 
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 |   2 
src/main/res/drawable-hdpi/ic_missed_call_notification.png                |   0 
src/main/res/drawable-mdpi/ic_missed_call_notification.png                |   0 
src/main/res/drawable-xhdpi/ic_missed_call_notification.png               |   0 
src/main/res/drawable-xxhdpi/ic_missed_call_notification.png              |   0 
src/main/res/drawable-xxxhdpi/ic_missed_call_notification.png             |   0 
src/main/res/values/strings.xml                                           |   5 
12 files changed, 608 insertions(+), 20 deletions(-)

Detailed changes

art/ic_missed_call_notification.svg 🔗

@@ -0,0 +1,344 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="512"
+   height="512"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="ic_missed_call_notification.svg"
+   inkscape:export-filename="/home/diesys/diesys/grafica/conversation/conversation_bubble.png"
+   inkscape:export-xdpi="100"
+   inkscape:export-ydpi="100">
+  <defs
+     id="defs4">
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient3874">
+      <stop
+         style="stop-color:#00a000;stop-opacity:1;"
+         offset="0"
+         id="stop3876" />
+      <stop
+         style="stop-color:#00a000;stop-opacity:0;"
+         offset="1"
+         id="stop3878" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient3913">
+      <stop
+         style="stop-color:#ffffff;stop-opacity:1;"
+         offset="0"
+         id="stop3915" />
+      <stop
+         style="stop-color:#ffffff;stop-opacity:0;"
+         offset="1"
+         id="stop3917" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient3818">
+      <stop
+         style="stop-color:#669900;stop-opacity:1"
+         offset="0"
+         id="stop3820" />
+      <stop
+         style="stop-color:#99cc00;stop-opacity:1"
+         offset="1"
+         id="stop3822" />
+    </linearGradient>
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3818"
+       id="radialGradient3824"
+       cx="212.07048"
+       cy="1045.9178"
+       fx="212.07048"
+       fy="1045.9178"
+       r="238.57143"
+       gradientTransform="matrix(1.9491621,-0.90817722,0.65829208,1.4128498,-879.63121,-248.98648)"
+       gradientUnits="userSpaceOnUse" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3913"
+       id="radialGradient3919"
+       cx="362.98563"
+       cy="379.77524"
+       fx="362.98563"
+       fy="379.77524"
+       r="139.95312"
+       gradientTransform="matrix(1.3800477,1.0445431,-1.3325077,1.7605059,339.09383,-577.83938)"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       gradientUnits="userSpaceOnUse"
+       y2="-155.75885"
+       x2="114.59022"
+       y1="35.545681"
+       x1="114.55434"
+       id="linearGradient3794"
+       xlink:href="#linearGradient3788"
+       inkscape:collect="always" />
+    <linearGradient
+       id="linearGradient3788">
+      <stop
+         id="stop3790"
+         offset="0"
+         style="stop-color:#1eed00;stop-opacity:1;" />
+      <stop
+         id="stop3792"
+         offset="1"
+         style="stop-color:#abff28;stop-opacity:1;" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3821">
+      <stop
+         style="stop-color:#ff283d;stop-opacity:1;"
+         offset="0"
+         id="stop3823" />
+      <stop
+         style="stop-color:#ff28ae;stop-opacity:1;"
+         offset="1"
+         id="stop3825" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient4543">
+      <stop
+         style="stop-color:#2e45bf;stop-opacity:1;"
+         offset="0"
+         id="stop4545" />
+      <stop
+         style="stop-color:#28a7ff;stop-opacity:1;"
+         offset="1"
+         id="stop4547" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       id="linearGradient4098">
+      <stop
+         style="stop-color:#ffffff;stop-opacity:1;"
+         offset="0"
+         id="stop4100" />
+      <stop
+         style="stop-color:#e6e6e6;stop-opacity:1"
+         offset="1"
+         id="stop4102" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient4098"
+       id="linearGradient3833"
+       x1="273.81851"
+       y1="764.74677"
+       x2="304.14023"
+       y2="936.47272"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient4098"
+       id="linearGradient3853"
+       gradientUnits="userSpaceOnUse"
+       x1="273.81851"
+       y1="764.74677"
+       x2="304.14023"
+       y2="936.47272" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3818"
+       id="radialGradient3863"
+       cx="262.33273"
+       cy="945.23846"
+       fx="262.33273"
+       fy="945.23846"
+       r="185.49754"
+       gradientTransform="matrix(1.2253203,-0.54206726,0.43090148,0.97403458,-466.4135,170.11831)"
+       gradientUnits="userSpaceOnUse" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3818"
+       id="radialGradient3866"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.2253203,-0.54206726,0.43090148,0.97403458,-466.4135,170.11831)"
+       cx="262.33273"
+       cy="945.23846"
+       fx="262.33273"
+       fy="945.23846"
+       r="185.49754" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3913"
+       id="radialGradient3873"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.3800477,1.0445431,-1.3325077,1.7605059,339.09383,-577.83938)"
+       cx="321.75275"
+       cy="386.38751"
+       fx="321.75275"
+       fy="386.38751"
+       r="139.95312" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3818"
+       id="radialGradient3880"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.2253203,-0.54206726,0.43090148,0.97403458,-466.4135,-370.24387)"
+       cx="262.33273"
+       cy="945.23846"
+       fx="262.33273"
+       fy="945.23846"
+       r="185.49754" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3913"
+       id="radialGradient3883"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.4430075,-0.63865195,0.50745433,1.1475866,-594.40824,44.803037)"
+       cx="262.33273"
+       cy="945.23846"
+       fx="262.33273"
+       fy="945.23846"
+       r="185.49754" />
+    <radialGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3874"
+       id="radialGradient3881"
+       cx="150.35715"
+       cy="236.28571"
+       fx="150.35715"
+       fy="236.28571"
+       r="26.887305"
+       gradientTransform="matrix(1,0,0,0.98671703,0,3.1385771)"
+       gradientUnits="userSpaceOnUse" />
+  </defs>
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1.4142136"
+     inkscape:cx="260.34974"
+     inkscape:cy="246.85245"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     inkscape:window-width="956"
+     inkscape:window-height="1039"
+     inkscape:window-x="960"
+     inkscape:window-y="18"
+     inkscape:window-maximized="0"
+     showguides="false"
+     inkscape:guide-bbox="true"
+     inkscape:snap-to-guides="true"
+     inkscape:snap-grids="false"
+     inkscape:object-paths="true"
+     inkscape:object-nodes="false"
+     inkscape:snap-nodes="false"
+     inkscape:pagecheckerboard="true">
+    <sodipodi:guide
+       orientation="1,0"
+       position="0,534.28571"
+       id="guide3004"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="0,1"
+       position="394.28571,511.42857"
+       id="guide3006"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="1,0"
+       position="511.42857,320"
+       id="guide3008"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="0,1"
+       position="401.42857,0"
+       id="guide3010"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="1,0"
+       position="17.142857,258.57143"
+       id="guide3012"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="0,1"
+       position="327.14286,494.28571"
+       id="guide3014"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="0,1"
+       position="324.28571,17.142857"
+       id="guide3016"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="1,0"
+       position="494.28571,237.14286"
+       id="guide3018"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="1,0"
+       position="255.71429,302.85714"
+       id="guide3022"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="1,0"
+       position="660,-315"
+       id="guide3904"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="0,1"
+       position="554.28571,475.71429"
+       id="guide3931"
+       inkscape:locked="false" />
+    <sodipodi:guide
+       orientation="0,1"
+       position="581.42857,244.28571"
+       id="guide3933"
+       inkscape:locked="false" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-540.36218)"
+     style="display:inline">
+    <path
+       style="fill:#ffffff;fill-opacity:1;stroke:none"
+       d="m 253.21875,17.71875 c -127.0747,0 -230.75,101.16492 -230.75,226.03125 0,124.86632 103.66932,226.09375 230.75,226.09375 39.52056,0 69.99755,-8.10776 104.78125,-20.75 L 468.46875,493.625 c 11.02016,4.46685 22.45453,-5.45389 19.59375,-17 L 458.125,355.65625 C 477.35631,321.88611 483.9375,283.41561 483.9375,243.75 483.9375,118.88673 380.29349,17.71875 253.21875,17.71875 Z m 127.29102,163.70508 23.93164,23.93164 -141.7461,141.74609 -107.1914,-107.1914 v 72.28125 H 121.6582 v -130.0586 h 130.06055 v 33.84375 h -72.2832 l 83.25976,83.26172 z"
+       transform="translate(0,540.36218)"
+       id="path3868"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="sssccccsscccccccccccc" />
+    <path
+       sodipodi:nodetypes="ccsssscc"
+       inkscape:connector-curvature="0"
+       id="path3845"
+       d="M 478.64112,1025.218 447.36049,898.60749 c 19.89028,-31.99834 26.74288,-69.57172 26.74288,-109.76189 0,-116.81686 -96.79943,-211.48385 -216.18374,-211.48385 -119.38425,0 -216.183656,94.66699 -216.183656,211.48385 0,116.81685 96.799406,211.5536 216.183656,211.5536 39.63617,0 68.58847,-8.14219 105.19417,-21.76075 z"
+       style="opacity:0;fill:none;stroke:#000000;stroke-width:23.55835724;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:94.23343197, 94.23343197;stroke-dashoffset:0" />
+  </g>
+  <g
+     inkscape:groupmode="layer"
+     id="layer4"
+     inkscape:label="Dots" />
+</svg>

art/render.rb 🔗

@@ -28,6 +28,7 @@ images = {
 	'conversations_mono.svg' => ['conversations/ic_notification', 24],
     'quicksy_mono.svg' => ['quicksy/ic_notification', 24],
     'flip_camera_android-black-24dp.svg' => ['ic_flip_camera_android_black_24dp', 24],
+    'ic_missed_call_notification.svg' => ['ic_missed_call_notification', 24],
 	'ic_send_text_offline.svg' => ['ic_send_text_offline', 36],
 	'ic_send_text_offline_white.svg' => ['ic_send_text_offline_white', 36],
 	'ic_send_text_online.svg' => ['ic_send_text_online', 36],
@@ -119,7 +120,7 @@ images.each do |source_filename, settings|
         else
             path = "../src/#{output_parts[0]}/res/drawable-#{resolution}/#{output_parts[1]}.png"
         end
-		execute_cmd "#{inkscape} #{source_filename} -C -w #{width} -h #{height} -o #{path}"
+		execute_cmd "#{inkscape} #{source_filename} -C -w #{width} -h #{height} -e #{path}"
 
 		top = []
 		right = []

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -241,11 +241,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 🔗

@@ -87,7 +87,8 @@ public class NotificationService {
 
     private static final long[] CALL_PATTERN = {0, 500, 300, 600};
 
-    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;
@@ -95,9 +96,11 @@ public class NotificationService {
     private static final int INCOMING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 8;
     public static final int ONGOING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 10;
     private static final int DELIVERY_FAILED_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 12;
+    public static final int MISSED_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 14;
     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;
@@ -196,6 +199,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),
@@ -247,12 +259,18 @@ public class NotificationService {
         notificationManager.createNotificationChannel(deliveryFailedChannel);
     }
 
-    private 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() {
@@ -276,11 +294,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);
+            }
         }
     }
 
@@ -315,6 +337,9 @@ public class NotificationService {
                 updateNotification(count > 0, conversations);
             }
         }
+        synchronized (mMissedCalls) {
+            updateMissedCallNotifications(mMissedCalls.keySet());
+        }
     }
 
     private List<String> getBacklogConversations(Account account) {
@@ -562,7 +587,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;
         }
@@ -582,7 +607,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);
@@ -592,7 +639,7 @@ public class NotificationService {
         }
     }
 
-    public void clear(final Conversation conversation) {
+    public void clearMessages(final Conversation conversation) {
         synchronized (this.mBacklogMessageCounter) {
             this.mBacklogMessageCounter.remove(conversation);
         }
@@ -605,6 +652,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()));
     }
@@ -672,7 +738,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());
                     }
@@ -682,6 +748,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));
@@ -730,6 +821,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_missed_call_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_missed_call_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();
@@ -767,7 +953,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;
@@ -1069,7 +1255,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);
@@ -1077,6 +1263,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(final Conversation conversation, final String lastMessageUuid, final boolean dismissAfterReply) {
         final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
         intent.setAction(XmppConnectionService.ACTION_REPLY_TO_CONVERSATION);
@@ -1332,6 +1528,29 @@ public class NotificationService {
         public void run() {
             final Vibrator vibrator = (Vibrator) mXmppConnectionService.getSystemService(Context.VIBRATOR_SERVICE);
             vibrator.vibrate(CALL_PATTERN, -1);
+			}
+		}
+
+    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 🔗

@@ -172,7 +172,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";
@@ -677,19 +678,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;
@@ -776,7 +793,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:
@@ -1945,7 +1962,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(mNotificationService::pushFromBacklog);
+        conversation.findUnreadMessagesAndCalls(mNotificationService::pushFromBacklog);
     }
 
     public void loadPhoneContacts() {

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java 🔗

@@ -865,6 +865,7 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
                 rejectCallFromSessionInitiate();
                 break;
         }
+        xmppConnectionService.getNotificationService().pushMissedCallNow(message);
     }
 
     private void cancelRingingTimeout() {
@@ -916,6 +917,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 🔗

@@ -765,6 +765,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="delivery_failed_channel_name">Failed deliveries</string>
@@ -936,6 +937,10 @@
     <string name="outgoing_call_duration">Outgoing call (%s)</string>
     <string name="outgoing_call_duration_timestamp">Outgoing call (%s) . %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>