From 5608d649b0dfeb514b595d3b05d19515eb2a9cb8 Mon Sep 17 00:00:00 2001 From: yenda Date: Thu, 26 Mar 2020 13:02:42 +0100 Subject: [PATCH] notifications: enable local notifications - only show notifications when app is in background - open the user chat upon tap on notification - remove chat from notifications on tap or dismiss notification - keep the service alive on host destroy - enable local notifications --- .env.release | 1 - .../module/NewMessageSignalHandler.java | 202 ++++++++++++------ .../status/ethereum/module/StatusModule.java | 8 +- src/status_im/utils/universal_links/core.cljs | 9 + 4 files changed, 151 insertions(+), 69 deletions(-) diff --git a/.env.release b/.env.release index f8dfc5113c..bf67346107 100644 --- a/.env.release +++ b/.env.release @@ -17,4 +17,3 @@ SNOOPY=0 RPC_NETWORKS_ONLY=1 PARTITIONED_TOPIC=0 MOBILE_UI_FOR_DESKTOP=1 -LOCAL_NOTIFICATIONS=0 diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java index c4b16a4311..27caffe247 100644 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java @@ -3,6 +3,8 @@ package im.status.ethereum.module; import android.content.Context; import android.content.ContentResolver; import android.content.Intent; +import android.content.IntentFilter; +import android.content.BroadcastReceiver; import org.json.JSONException; import org.json.JSONObject; @@ -25,6 +27,7 @@ import android.graphics.BitmapFactory; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; +import android.app.PendingIntent; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationCompat; @@ -35,7 +38,14 @@ import android.media.AudioAttributes; import android.util.Log; -class NewMessageSignalHandler { +public class NewMessageSignalHandler { + //NOTE: currently we only show notifications for 1-1 chats, in the future we + //will most likely extend to other kind of notifications. The first step will + //be to define actions for these notifications, add it to the filter in + //`registerBroadcastReceiver` method, and add some action specific code + //in notificationActionReceiver. + public static final String ACTION_DELETE_NOTIFICATION = "im.status.ethereum.module.DELETE_NOTIFICATION"; + public static final String ACTION_TAP_NOTIFICATION = "im.status.ethereum.module.TAP_NOTIFICATION"; private static final String GROUP_STATUS_MESSAGES = "im.status.notifications.message"; private static final String CHANNEL_NAME = "Status"; private static final String CHANNEL_DESCRIPTION = "Get notifications on new messages and mentions"; @@ -48,6 +58,64 @@ class NewMessageSignalHandler { private Intent serviceIntent; private Boolean shouldRefreshNotifications; + //NOTE: we use a dynamically created BroadcastReceiver here so that we can capture + //intents from notifications and act on them. For instance when tapping/dismissing + //a chat notification we want to clear the chat so that next messages don't show + //the messages that we have seen again + private final BroadcastReceiver notificationActionReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String chatId = intent.getExtras().getString("im.status.ethereum.chatId"); + if (intent.getAction() == ACTION_TAP_NOTIFICATION) { + context.startActivity(getOpenAppIntent(chatId)); + } + removeChat(chatId); + // clean up the group notifications when there is no + // more unread chats + if (chats.size() == 0) { + notificationManager.cancelAll(); + } + Log.e(TAG, "intent received: " + intent.getAction()); + } + }; + + private void registerBroadcastReceiver() { + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_DELETE_NOTIFICATION); + filter.addAction(ACTION_TAP_NOTIFICATION); + context.registerReceiver(notificationActionReceiver, filter); + Log.e(TAG, "Broadcast Receiver registered"); + } + + //NOTE: this method takes a chatId and returns an intent that will open the app in that chat + //Once we support other kind of notifications we will need to adapt it. The simplest method + //is probably to pass the universal link as param instead of the chatId. + public Intent getOpenAppIntent(String chatId) { + Class intentClass; + String packageName = context.getPackageName(); + Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); + String className = launchIntent.getComponent().getClassName(); + try { + intentClass = Class.forName(className); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + return null; + } + Intent intent = new Intent(context, intentClass); + intent.addCategory(Intent.CATEGORY_BROWSABLE); + intent.setAction(Intent.ACTION_VIEW); + //NOTE: you might wonder, why the heck did he decide to set these flags in particular. Well, + //the answer is a simple as it can get in the Android native development world. I noticed + //that my initial setup was opening the app but wasn't triggering any events on the js side, like + //the links do from the browser. So I compared both intents and noticed that the link from + //the browser produces an intent with the flag 0x14000000. I found out that it was the following + //flags in this link: + //https://stackoverflow.com/questions/52390129/android-intent-setflags-issue + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setData(Uri.parse("status-im://chat/private/" + chatId)); + return intent; + } + public NewMessageSignalHandler(Context context) { // NOTE: when instanciated the NewMessageSignalHandler class starts a foreground service // to keep the app running in the background in order to receive notifications @@ -61,12 +129,17 @@ class NewMessageSignalHandler { Log.e(TAG, "Starting Foreground Service"); Intent serviceIntent = new Intent(context, ForegroundService.class); context.startService(serviceIntent); + this.registerBroadcastReceiver(); } public void stop() { Log.e(TAG, "Stopping Foreground Service"); + //NOTE: we cancel all the current notifications, because the intents can't be used anymore + //since the broadcast receiver will be killed as well and won't be able to handle any intent + notificationManager.cancelAll(); Intent serviceIntent = new Intent(context, ForegroundService.class); context.stopService(serviceIntent); + context.unregisterReceiver(notificationActionReceiver); } private void createNotificationChannel() { @@ -77,30 +150,49 @@ class NewMessageSignalHandler { NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); channel.setDescription(CHANNEL_DESCRIPTION); AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .build(); + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); channel.setSound(soundUri, audioAttributes); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } + private void removeChat(String chatId) { + this.chats.remove(chatId); + } + + private PendingIntent createOnDismissedIntent(Context context, int notificationId, String chatId) { + Intent intent = new Intent(ACTION_DELETE_NOTIFICATION); + intent.putExtra("im.status.ethereum.chatId", chatId); + return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + private PendingIntent createOnTapIntent(Context context, int notificationId, String chatId) { + Intent intent = new Intent(ACTION_TAP_NOTIFICATION); + intent.putExtra("im.status.ethereum.chatId", chatId); + return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + public void notify(int notificationId, StatusChat chat) { NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle("Me"); ArrayList messages = chat.getMessages(); for (int i = 0; i < messages.size(); i++) { StatusMessage message = messages.get(i); messagingStyle.addMessage(message.getText(), - message.getTimestamp(), - message.getAuthor()); + message.getTimestamp(), + message.getAuthor()); } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_notify_status) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setStyle(messagingStyle) - .setGroup(GROUP_STATUS_MESSAGES); + .setGroup(GROUP_STATUS_MESSAGES) + .setGroupSummary(true) + .setContentIntent(createOnTapIntent(context, notificationId, chat.getId())) + .setDeleteIntent(createOnDismissedIntent(context, notificationId, chat.getId())) + .setAutoCancel(true); if (Build.VERSION.SDK_INT >= 21) { builder.setVibrate(new long[0]); } @@ -109,33 +201,13 @@ class NewMessageSignalHandler { public void refreshNotifications() { NotificationCompat.InboxStyle summaryStyle = new NotificationCompat.InboxStyle(); - String summary = ""; int notificationId = 2; // we start at 2 because the service is using 1 and can't use 0 - int messageCounter = 0; Iterator chatIterator = chats.values().iterator(); while(chatIterator.hasNext()) { StatusChat chat = (StatusChat)chatIterator.next(); notify(notificationId, chat); notificationId++; - messageCounter += chat.getMessages().size(); - summaryStyle.addLine(chat.getSummary()); - summary += chat.getSummary() + "\n"; } - // NOTE: this is necessary for old versions of Android, newer versions are - // building this group themselves and I was not able to make any change to - // what this group displays - NotificationCompat.Builder groupBuilder = - new NotificationCompat.Builder(context, CHANNEL_ID) - .setContentText(summary) - .setSmallIcon(R.drawable.ic_stat_notify_status) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setContentTitle("You got " + messageCounter + " messages in " + chats.size() + " chats") - .setStyle(summaryStyle - .setBigContentTitle("You got " + messageCounter + " messages in " + chats.size() + " chats")) - .setGroup(GROUP_STATUS_MESSAGES) - .setGroupSummary(true); - notificationManager.notify(notificationId, groupBuilder.build()); } void handleNewMessageSignal(JSONObject newMessageSignal) { @@ -207,7 +279,7 @@ class NewMessageSignalHandler { chat = new StatusChat(id, true); } - chats.put(id, chat); + chats.put(id, chat); } } catch (JSONException e) { Log.e(TAG, "JSON conversion failed: " + e.getMessage()); @@ -217,34 +289,34 @@ class NewMessageSignalHandler { private void upsertMessage(JSONObject messageData) { - try { - String chatId = messageData.getString("localChatId"); - StatusChat chat = chats.get(chatId); - if (chat == null) { - return; - } + try { + String chatId = messageData.getString("localChatId"); + StatusChat chat = chats.get(chatId); + if (chat == null) { + return; + } - StatusMessage message = createMessage(messageData); - if (message != null) { - chat.appendMessage(message); - chats.put(chatId, chat); - shouldRefreshNotifications = true; - } + StatusMessage message = createMessage(messageData); + if (message != null) { + chat.appendMessage(message); + chats.put(chatId, chat); + shouldRefreshNotifications = true; + } - } - catch (JSONException e) { - Log.e(TAG, "JSON conversion failed: " + e.getMessage()); - } + } + catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } } private StatusMessage createMessage(JSONObject messageData) { - try { - Person author = getPerson(messageData.getString("from"), messageData.getString("identicon"), messageData.getString("alias")); - return new StatusMessage(author, messageData.getLong("whisperTimestamp"), messageData.getString("text")); - } catch (JSONException e) { - Log.e(TAG, "JSON conversion failed: " + e.getMessage()); - } - return null; + try { + Person author = getPerson(messageData.getString("from"), messageData.getString("identicon"), messageData.getString("alias")); + return new StatusMessage(author, messageData.getLong("whisperTimestamp"), messageData.getString("text")); + } catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } + return null; } } @@ -267,22 +339,22 @@ class StatusChat { public String getName() { - //TODO this should be improved as it would rename the chat - // after our own user if we were posting from another device - // in 1-1 chats it should be the name of the user whose - // key is different than ours - StatusMessage message = getLastMessage(); - if (message == null) { - return "no-name"; - } - return message.getAuthor().getName().toString(); + //TODO this should be improved as it would rename the chat + // after our own user if we were posting from another device + // in 1-1 chats it should be the name of the user whose + // key is different than ours + StatusMessage message = getLastMessage(); + if (message == null) { + return "no-name"; + } + return message.getAuthor().getName().toString(); } private StatusMessage getLastMessage() { - if (messages.size() > 0) { - return messages.get(messages.size()-1); - } - return null; + if (messages.size() > 0) { + return messages.get(messages.size()-1); + } + return null; } public long getTimestamp() { @@ -294,7 +366,7 @@ class StatusChat { } public void appendMessage(StatusMessage message) { - this.messages.add(message); + this.messages.add(message); } public String getSummary() { diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java index 78657ef934..94a27f9206 100644 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java @@ -69,6 +69,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL private ReactApplicationContext reactContext; private boolean rootedDevice; private NewMessageSignalHandler newMessageSignalHandler; + private boolean background; StatusModule(ReactApplicationContext reactContext, boolean rootedDevice) { super(reactContext); @@ -85,17 +86,18 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL @Override public void onHostResume() { // Activity `onResume` module = this; + this.background = false; Statusgo.setMobileSignalHandler(this); } @Override public void onHostPause() { + this.background = true; } @Override public void onHostDestroy() { - Intent intent = new Intent(getReactApplicationContext(), ForegroundService.class); - getReactApplicationContext().stopService(intent); + Log.d(TAG, "******************* ON HOST DESTROY *************************"); } @ReactMethod @@ -145,7 +147,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL Log.d(TAG, "Signal event: " + jsonEventString); // NOTE: the newMessageSignalHandler is only instanciated if the user // enabled notifications in the app - if (newMessageSignalHandler != null) { + if (this.background && newMessageSignalHandler != null) { if (eventType.equals("messages.new")) { newMessageSignalHandler.handleNewMessageSignal(jsonEvent); } diff --git a/src/status_im/utils/universal_links/core.cljs b/src/status_im/utils/universal_links/core.cljs index 57daf6c04b..ed74d871c4 100644 --- a/src/status_im/utils/universal_links/core.cljs +++ b/src/status_im/utils/universal_links/core.cljs @@ -21,6 +21,7 @@ ;; TODO(yenda) investigate why `handle-universal-link` event is ;; dispatched 7 times for the same link +(def private-chat-regex #".*/chat/private/(.*)$") (def public-chat-regex #"(?:https?://join\.)?status[.-]im(?::/)?/(?:chat/public/([a-z0-9\-]+)$|([a-z0-9\-]+))$") (def profile-regex #"(?:https?://join\.)?status[.-]im(?::/)?/(?:u/(0x.*)$|u/(.*)$|user/(.*))$") (def browse-regex #"(?:https?://join\.)?status[.-]im(?::/)?/(?:b/(.*)$|browse/(.*))$") @@ -30,6 +31,7 @@ :internal "status-im:/"}) (def links {:public-chat "%s/%s" + :private-chat "%s/p/%s" :user "%s/u/%s" :browse "%s/b/%s"}) @@ -71,6 +73,10 @@ (when (security/safe-link? url) {:browser/show-browser-selection url})) +(fx/defn handle-private-chat [cofx chat-id] + (log/info "universal-links: handling private chat" chat-id) + (chat/start-chat cofx chat-id {})) + (fx/defn handle-public-chat [cofx public-chat] (log/info "universal-links: handling public chat" public-chat) (chat/start-public-chat cofx public-chat {})) @@ -120,6 +126,9 @@ [cofx url] (cond + (match-url url private-chat-regex) + (handle-private-chat cofx (match-url url private-chat-regex)) + (spec/valid? :global/public-key (match-url url profile-regex)) (handle-view-profile cofx {:public-key (match-url url profile-regex)})