From 116b4d88125b20342ceb031f88f58e8f32f9f8e4 Mon Sep 17 00:00:00 2001 From: Andrea Maria Piana Date: Thu, 4 Mar 2021 10:27:55 +0100 Subject: [PATCH] Add community notifications on android & IOS [Fixes: #11806] [Fixes: #11877] Features added An admin should receive a notification on android if a community request is made Clicking on the push notification will take you to the list of requests Changes to push notifications Push notification for messages are grouped by chat, similarly to discord. Only the first notification in a chat will alert, the next will be "silent" notification. Meaning you will see the message but it will not alert. This is consistent with discord/whatsapp. Clicking on a transaction notification will take you to wallet (not sure it was doing that before, but it might have) Changed the behavior of the notification toggle, before Notifications and wallet transactions were separate, meaning you could have wallet transaction and Notifications disabled and you'd still receive transactions notifications. Now you need to have Notifications enabled to receive wallet transactions. Eventually we will have an option to toggle message notifications. Technical details Removes headless tasks, they were not used Message notifications are passed unchanged to java, we still check if the chat is in the foreground, but no modifications are made to the notification object. This should get us closer to avoid clojure completely. Merged the two notifications implementation (NewMessageSignalHandler and PushNotificationHelper). We should split maybe off in more meaningful classes, but there's less code duplication now, and it can be re-used for non-chat (communities) notifications. Parsing of text for message notifications is done in status-go Signal is not passed to status-react if notifications are not enabled Next step To completely remove notification code from status-react the following are required: Java needs to be alerted of which chat is in the foreground and whether the app is in the foreground Transaction notification body message need to be created in status-go Notification signal needs to be stopped in Java Limitations If the name of a contact changes, the notification will not change, once is displayed, it won't be modified --- android/app/src/main/AndroidManifest.xml | 1 - .../module/LocalNotificationsService.java | 20 - .../pushnotifications/ForegroundService.java | 2 +- .../NewMessageSignalHandler.java | 381 ------------------ .../pushnotifications/PushNotification.java | 27 +- .../PushNotificationHelper.java | 255 +++++++++++- src/status_im/core.cljs | 2 - src/status_im/notifications/local.cljs | 99 +---- src/status_im/router/core.cljs | 4 + src/status_im/utils/universal_links/core.cljs | 33 +- status-go-version.json | 6 +- .../atomic/account_management/test_profile.py | 2 +- .../tests/atomic/chats/test_group_chat.py | 1 + .../tests/atomic/chats/test_one_to_one.py | 2 +- 14 files changed, 304 insertions(+), 531 deletions(-) delete mode 100644 modules/react-native-status/android/src/main/java/im/status/ethereum/module/LocalNotificationsService.java delete mode 100644 modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/NewMessageSignalHandler.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d89194b6ce..33ae65cbfa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -71,7 +71,6 @@ - persons; - private HashMap chats; - private Context context; - private Intent serviceIntent; - private Boolean shouldRefreshNotifications; - private int ONE_TO_ONE_CHAT_TYPE = 1; - private int PRIVATE_GROUP_CHAT_TYPE = 3; - - //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) { - if (intent.getAction() == ACTION_TAP_NOTIFICATION || - intent.getAction() == ACTION_DELETE_NOTIFICATION) { - String chatId = intent.getExtras().getString("im.status.ethereum.chatId"); - int chatType = intent.getExtras().getInt("im.status.ethereum.chatType"); - if (intent.getAction() == ACTION_TAP_NOTIFICATION) { - context.startActivity(getOpenAppIntent(chatId, chatType)); - } - removeChat(chatId); - // clean up the group notifications when there is no - // more unread chats - if (chats.size() == 0) { - notificationManager.cancelAll(); - }} - if (intent.getAction() == ACTION_TAP_STOP) { - stop(); - System.exit(0); - } - Log.e(TAG, "intent received: " + intent.getAction()); - } - }; - - private void registerBroadcastReceiver() { - IntentFilter filter = new IntentFilter(); - filter.addAction(ACTION_DELETE_NOTIFICATION); - filter.addAction(ACTION_TAP_NOTIFICATION); - filter.addAction(ACTION_TAP_STOP); - context.registerReceiver(notificationActionReceiver, filter); - Log.e(TAG, "Broadcast Receiver registered"); - } - - public Intent getOpenAppIntent() { - 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); - return intent; - } - - //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, int chatType) { - Intent intent = getOpenAppIntent(); - String path = ""; - if (chatType == ONE_TO_ONE_CHAT_TYPE) { - path = "p/"; - } else if (chatType == PRIVATE_GROUP_CHAT_TYPE) { - path = "g/args?a2="; - } - intent.setData(Uri.parse("status-im://" + path + 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 - // call the stop() method in order to stop the service - this.context = context; - this.persons = new HashMap(); - this.chats = new HashMap(); - this.notificationManager = context.getSystemService(NotificationManager.class); - this.createNotificationChannel(); - this.shouldRefreshNotifications = false; - 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() { - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Uri soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/" + R.raw.notification_sound); - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); - channel.setDescription(context.getResources().getString(R.string.channel_description)); - AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .build(); - channel.setSound(soundUri, audioAttributes); - channel.setShowBadge(true); - 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, int chatType) { - Intent intent = new Intent(ACTION_DELETE_NOTIFICATION); - intent.putExtra("im.status.ethereum.chatId", chatId); - intent.putExtra("im.status.ethereum.chatType", chatType); - return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - - private PendingIntent createOnTapIntent(Context context, int notificationId, String chatId, int chatType) { - Intent intent = new Intent(ACTION_TAP_NOTIFICATION); - intent.putExtra("im.status.ethereum.chatId", chatId); - intent.putExtra("im.status.ethereum.chatType", chatType); - 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()); - } - 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) - .setGroupSummary(true) - .setContentIntent(createOnTapIntent(context, notificationId, chat.getId(), chat.getType())) - .setDeleteIntent(createOnDismissedIntent(context, notificationId, chat.getId(), chat.getType())) - .setNumber(messages.size()) - .setAutoCancel(true); - if (Build.VERSION.SDK_INT >= 21) { - builder.setVibrate(new long[0]); - } - notificationManager.notify(notificationId, builder.build()); - } - - public void refreshNotifications() { - NotificationCompat.InboxStyle summaryStyle = new NotificationCompat.InboxStyle(); - int notificationId = 2; // we start at 2 because the service is using 1 and can't use 0 - Iterator chatIterator = chats.values().iterator(); - while(chatIterator.hasNext()) { - StatusChat chat = (StatusChat)chatIterator.next(); - notify(notificationId, chat); - notificationId++; - } - } - - void handleNewMessage (Bundle data) { - upsertChat(data); - upsertMessage(data); - - if(shouldRefreshNotifications) { - refreshNotifications(); - shouldRefreshNotifications = false; - } - } - - private Person getPerson(String publicKey, String icon, String name) { - // TODO: invalidate cache if icon and name are not the same as - // the Person returned (in case the user set a different icon or username for instance) - // not critical it's just for notifications at the moment - // using a HashMap to cache Person because it's immutable - Person person = persons.get(publicKey); - if (person == null) { - String base64Image = icon.split(",")[1]; - byte[] decodedString = Base64.decode(base64Image, Base64.DEFAULT); - Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length); - person = new Person.Builder().setIcon(IconCompat.createWithBitmap(decodedByte)).setName(name).build(); - persons.put(publicKey, person); - } - return person; - } - - private void upsertChat(Bundle data) { - String id = data.getString("chatId"); - int type = Integer.parseInt(data.getString("chatType")); - StatusChat chat = chats.get(id); - - // if the chat was not already there, we create one - if (chat == null) { - chat = new StatusChat(id, type); - } - - chats.put(id, chat); - } - - private void upsertMessage(Bundle data) { - String chatId = data.getString("chatId"); - StatusChat chat = chats.get(chatId); - if (chat == null) { - return; - } - - StatusMessage message = createMessage(data); - if (message != null) { - chat.appendMessage(message); - chats.put(chatId, chat); - shouldRefreshNotifications = true; - } - } - - private StatusMessage createMessage(Bundle data) { - Person author = getPerson(data.getString("from"), data.getString("identicon"), data.getString("alias")); - return new StatusMessage(author, data.getLong("whisperTimestamp"), data.getString("text")); - } -} - -class StatusChat { - private ArrayList messages; - private String id; - private String name; - private int type; - - StatusChat(String id, int type) { - this.id = id; - this.type = type; - this.messages = new ArrayList(); - this.name = name; - } - - public String getId() { - return id; - } - - public int getType() { - return this.type; - } - - 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(); - } - - private StatusMessage getLastMessage() { - if (messages.size() > 0) { - return messages.get(messages.size()-1); - } - return null; - } - - public long getTimestamp() { - return getLastMessage().getTimestamp(); - } - - public ArrayList getMessages() { - return messages; - } - - public void appendMessage(StatusMessage message) { - this.messages.add(message); - } - - public String getSummary() { - return "" + getLastMessage().getAuthor().getName() + ": " + getLastMessage().getText(); - } -} - - -class StatusMessage { - public Person getAuthor() { - return author; - } - - public long getTimestamp() { - return timestamp; - } - - public String getText() { - return text; - } - - private Person author; - private long timestamp; - private String text; - - StatusMessage(Person author, long timestamp, String text) { - this.author = author; - this.timestamp = timestamp; - this.text = text; - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java index 4ccd38f394..ce7a2e71b8 100644 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java @@ -37,7 +37,7 @@ public class PushNotification extends ReactContextBaseJavaModule implements Acti private PushNotificationHelper pushNotificationHelper; private PushNotificationJsDelivery delivery; private ReactApplicationContext reactContext; - private NewMessageSignalHandler newMessageSignalHandler; + private boolean started; public PushNotification(ReactApplicationContext reactContext) { super(reactContext); @@ -104,33 +104,30 @@ public class PushNotification extends ReactContextBaseJavaModule implements Acti @ReactMethod public void presentLocalNotification(ReadableMap details) { + if (!this.started) { + return; + } + Bundle bundle = Arguments.toBundle(details); // If notification ID is not provided by the user, generate one at random if (bundle.getString("id") == null) { bundle.putString("id", String.valueOf(mRandomNumberGenerator.nextInt())); } - String type = bundle.getString("type"); - if (type != null && type.equals("message")) { - if (this.newMessageSignalHandler != null) { - newMessageSignalHandler.handleNewMessage(bundle); - } - } else { - pushNotificationHelper.sendToNotificationCentre(bundle); - } + pushNotificationHelper.sendToNotificationCentre(bundle); } @ReactMethod public void enableNotifications() { - this.newMessageSignalHandler = new NewMessageSignalHandler(reactContext); + this.started = true; + this.pushNotificationHelper.start(); } @ReactMethod public void disableNotifications() { - if (newMessageSignalHandler != null) { - newMessageSignalHandler.stop(); - newMessageSignalHandler = null; - } + if (this.started) { + this.started = false; + this.pushNotificationHelper.stop(); + } } - } diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java index 9f7f94697f..91490b5f1c 100644 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java @@ -11,6 +11,8 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.content.BroadcastReceiver; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.res.Resources; @@ -24,9 +26,12 @@ import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; import android.util.Log; +import android.util.Base64; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; +import androidx.core.app.Person; +import androidx.core.graphics.drawable.IconCompat; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReadableArray; @@ -43,8 +48,10 @@ import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; import java.util.List; +import java.util.HashMap; import java.util.Map; +import im.status.ethereum.module.R; import static im.status.ethereum.pushnotifications.PushNotification.LOG_TAG; public class PushNotificationHelper { @@ -53,11 +60,91 @@ public class PushNotificationHelper { private static final long DEFAULT_VIBRATION = 300L; private static final String CHANNEL_ID = "status-im-notifications"; + 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"; + public static final String ACTION_TAP_STOP = "im.status.ethereum.module.TAP_STOP"; + + private NotificationManager notificationManager; + + + private HashMap persons; + private HashMap messageGroups; public PushNotificationHelper(Application context) { this.context = context; + this.persons = new HashMap(); + this.messageGroups = new HashMap(); + this.notificationManager = context.getSystemService(NotificationManager.class); + this.registerBroadcastReceiver(); } + public Intent getOpenAppIntent(String deepLink) { + 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(deepLink)); + return intent; + } + + //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) { + if (intent.getAction() == ACTION_TAP_NOTIFICATION || + intent.getAction() == ACTION_DELETE_NOTIFICATION) { + String deepLink = intent.getExtras().getString("im.status.ethereum.deepLink"); + String groupId = intent.getExtras().getString("im.status.ethereum.groupId"); + if (intent.getAction() == ACTION_TAP_NOTIFICATION) { + context.startActivity(getOpenAppIntent(deepLink)); + } + if (groupId != null) { + removeGroup(groupId); + // clean up the group notifications when there is no + // more unread chats + if (messageGroups.size() == 0) { + notificationManager.cancelAll(); + }} + } + if (intent.getAction() == ACTION_TAP_STOP) { + stop(); + System.exit(0); + } + Log.e(LOG_TAG, "intent received: " + intent.getAction()); + } + }; + + private void registerBroadcastReceiver() { + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_DELETE_NOTIFICATION); + filter.addAction(ACTION_TAP_NOTIFICATION); + filter.addAction(ACTION_TAP_STOP); + context.registerReceiver(notificationActionReceiver, filter); + Log.e(LOG_TAG, "Broadcast Receiver registered"); + } + + + private NotificationManager notificationManager() { return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); } @@ -95,7 +182,12 @@ public class PushNotificationHelper { aggregator.setBigPictureUrl(context, bundle.getString("bigPictureUrl")); } + public void handleConversation(final Bundle bundle) { + this.addStatusMessage(bundle); + } + public void sendToNotificationCentreWithPicture(final Bundle bundle, Bitmap largeIconBitmap, Bitmap bigPictureBitmap) { + try { Class intentClass = getMainActivityClass(); if (intentClass == null) { @@ -103,6 +195,11 @@ public class PushNotificationHelper { return; } + if (bundle.getBoolean("isConversation")) { + this.handleConversation(bundle); + return; + } + if (bundle.getString("message") == null) { // this happens when a 'data' notification is received - we do not synthesize a local notification in this case Log.d(LOG_TAG, "Ignore this message if you sent data-only notification. Cannot send to notification centre because there is no 'message' field in: " + bundle); @@ -325,10 +422,10 @@ public class PushNotificationHelper { } } - int notificationID = Integer.parseInt(notificationIdString); + int notificationID = notificationIdString.hashCode(); - PendingIntent pendingIntent = PendingIntent.getActivity(context, notificationID, intent, - PendingIntent.FLAG_UPDATE_CURRENT); + notification.setContentIntent(createOnTapIntent(context, notificationID, bundle.getString("deepLink"))) + .setDeleteIntent(createOnDismissedIntent(context, notificationID, bundle.getString("deepLink"))); NotificationManager notificationManager = notificationManager(); @@ -367,7 +464,6 @@ public class PushNotificationHelper { notification.setUsesChronometer(bundle.getBoolean("usesChronometer", false)); notification.setChannelId(channel_id); - notification.setContentIntent(pendingIntent); JSONArray actionsArray = null; try { @@ -546,4 +642,155 @@ public class PushNotificationHelper { return false; } + private Person getPerson(Bundle bundle) { + String base64Image = bundle.getString("icon").split(",")[1]; + byte[] decodedString = Base64.decode(base64Image, Base64.DEFAULT); + Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length); + + String name = bundle.getString("name"); + + return new Person.Builder().setIcon(IconCompat.createWithBitmap(decodedByte)).setName(name).build(); + } + + private StatusMessage createMessage(Bundle data) { + Person author = getPerson(data.getBundle("notificationAuthor")); + return new StatusMessage(author, data.getLong("timestamp"), data.getString("message")); + } + + private PendingIntent createGroupOnDismissedIntent(Context context, int notificationId, String groupId, String deepLink) { + Intent intent = new Intent(ACTION_DELETE_NOTIFICATION); + intent.putExtra("im.status.ethereum.deepLink", deepLink); + intent.putExtra("im.status.ethereum.groupId", groupId); + return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + private PendingIntent createGroupOnTapIntent(Context context, int notificationId, String groupId, String deepLink) { + Intent intent = new Intent(ACTION_TAP_NOTIFICATION); + intent.putExtra("im.status.ethereum.deepLink", deepLink); + intent.putExtra("im.status.ethereum.groupId", groupId); + return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + private PendingIntent createOnTapIntent(Context context, int notificationId, String deepLink) { + Intent intent = new Intent(ACTION_TAP_NOTIFICATION); + intent.putExtra("im.status.ethereum.deepLink", deepLink); + return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + private PendingIntent createOnDismissedIntent(Context context, int notificationId, String deepLink) { + Intent intent = new Intent(ACTION_DELETE_NOTIFICATION); + intent.putExtra("im.status.ethereum.deepLink", deepLink); + return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + + public void addStatusMessage(Bundle bundle) { + String conversationId = bundle.getString("conversationId"); + StatusMessageGroup group = this.messageGroups.get(conversationId); + NotificationManager notificationManager = notificationManager(); + + if (group == null) { + group = new StatusMessageGroup(conversationId); + } + + this.messageGroups.put(conversationId, group); + + group.addMessage(createMessage(bundle)); + + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle("Me"); + ArrayList messages = group.getMessages(); + for (int i = 0; i < messages.size(); i++) { + StatusMessage message = messages.get(i); + messagingStyle.addMessage(message.getText(), + message.getTimestamp(), + message.getAuthor()); + } + + if (bundle.getString("title") != null) { + messagingStyle.setConversationTitle(bundle.getString("title")); + } + + 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(conversationId) + .setOnlyAlertOnce(true) + .setGroupSummary(true) + .setContentIntent(createGroupOnTapIntent(context, conversationId.hashCode(), conversationId, bundle.getString("deepLink"))) + .setDeleteIntent(createGroupOnDismissedIntent(context, conversationId.hashCode(), conversationId, bundle.getString("deepLink"))) + .setNumber(messages.size() + 1) + .setAutoCancel(true); + if (Build.VERSION.SDK_INT >= 21) { + builder.setVibrate(new long[0]); + } + notificationManager.notify(conversationId.hashCode(), builder.build()); + } + + class StatusMessageGroup { + private ArrayList messages; + private String id; + + StatusMessageGroup(String id) { + this.id = id; + this.messages = new ArrayList(); + } + public ArrayList getMessages() { + return messages; + } + + public void addMessage(StatusMessage message) { + this.messages.add(message); + } + + public String getId() { + return this.id; + } + } + + class StatusMessage { + public Person getAuthor() { + return author; + } + + public long getTimestamp() { + return timestamp; + } + + public String getText() { + return text; + } + + private Person author; + private long timestamp; + private String text; + + StatusMessage(Person author, long timestamp, String text) { + this.author = author; + this.timestamp = timestamp; + this.text = text; + } + } + + private void removeGroup(String groupId) { + this.messageGroups.remove(groupId); + } + + public void start() { + Log.e(LOG_TAG, "Starting Foreground Service"); + Intent serviceIntent = new Intent(context, ForegroundService.class); + context.startService(serviceIntent); + this.registerBroadcastReceiver(); + } + + public void stop() { + Log.e(LOG_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); + } } diff --git a/src/status_im/core.cljs b/src/status_im/core.cljs index a57a271db7..f3c6698317 100644 --- a/src/status_im/core.cljs +++ b/src/status_im/core.cljs @@ -30,8 +30,6 @@ (status/set-soft-input-mode status/adjust-resize)) (.registerComponent ^js (.-AppRegistry rn) "StatusIm" #(reagent/reactify-component views/root)) (notifications/listen-notifications) - (when platform/android? - (.registerHeadlessTask ^js (.-AppRegistry rn) "LocalNotifications" notifications/handle)) (snoopy/subscribe!) (when (and js/goog.DEBUG platform/ios? DevSettings) ;;on Android this method doesn't work diff --git a/src/status_im/notifications/local.cljs b/src/status_im/notifications/local.cljs index 34efdaddce..cd6a80a22e 100644 --- a/src/status_im/notifications/local.cljs +++ b/src/status_im/notifications/local.cljs @@ -1,6 +1,5 @@ (ns status-im.notifications.local - (:require [taoensso.timbre :as log] - [status-im.utils.fx :as fx] + (:require [status-im.utils.fx :as fx] [status-im.ethereum.decode :as decode] ["@react-native-community/push-notification-ios" :default pn-ios] [status-im.notifications.android :as pn-android] @@ -14,11 +13,8 @@ [re-frame.core :as re-frame] [status-im.ui.components.react :as react] [cljs-bean.core :as bean] - [status-im.ui.screens.chat.components.reply :as reply] [clojure.string :as clojure.string] - [status-im.chat.models :as chat.models] - [status-im.constants :as constants] - [status-im.utils.identicon :as identicon])) + [status-im.chat.models :as chat.models])) (def default-erc20-token {:symbol :ERC20 @@ -39,21 +35,8 @@ {:notificationType "local-notification"}))}))) (defn local-push-android - [{:keys [title message icon user-info channel-id type] - :as notification - :or {channel-id "status-im-notifications"}}] - (when notification - (pn-android/present-local-notification - (merge {:channelId channel-id - :title title - :message message - :showBadge false} - (when user-info - {:userInfo (bean/->js user-info)}) - (when icon - {:largeIconUrl (:uri (react/resolve-asset-source icon))}) - (when (= type "message") - notification))))) + [notification] + (pn-android/present-local-notification notification)) (defn handle-notification-press [{{deep-link :deepLink} :userInfo interaction :userInteraction}] @@ -106,65 +89,16 @@ nil)] {:title title :icon (get-in token [:icon :source]) + :deepLink (:deepLink notification) :user-info notification :message description})) -(defn chat-by-message - [{:keys [chats]} {:keys [localChatId from]}] - (if-let [chat (get chats localChatId)] - (assoc chat :chat-id localChatId) - (assoc (get chats from) :chat-id from))) - (defn show-message-pn? [{{:keys [app-state]} :db :as cofx} - {{:keys [chat-id]} :chat}] - (or (= app-state "background") - (not (chat.models/foreground-chat? cofx chat-id)))) - -(defn create-message-notification - ([{:keys [db] :as cofx} {{:keys [message]} :body :as notification}] - (when-not (nil? cofx) - (let [chat (chat-by-message db message) - contact-id (get message :from) - contact (get-in db [:contacts/contacts contact-id]) - notification (assoc notification - :chat chat - :contact-id contact-id - :contact contact)] - (when (show-message-pn? cofx notification) - (create-message-notification notification))))) - ([{{:keys [message]} :body - {:keys [chat-type chat-id] :as chat} :chat - {:keys [identicon]} :contact - contact-id :contact-id}] - (when (and chat-type chat-id) - ;;TODO : DON'T USE SUBS IN EVENTS - (let [contact-name @(re-frame/subscribe - [:contacts/contact-name-by-identity contact-id]) - group-chat? (not= chat-type constants/one-to-one-chat-type) - title (clojure.string/join - " " - (cond-> [contact-name] - group-chat? - (conj - ;; TODO(rasom): to be translated - "in") - - group-chat? - (conj - (str (when (contains? #{constants/public-chat-type - constants/community-chat-type} - chat-type) - "#") - (get chat :name)))))] - {:type "message" - :chatType (str chat-type) - :from title - :chatId chat-id - :alias title - :identicon (or identicon (identicon/identicon contact-id)) - :whisperTimestamp (get message :whisperTimestamp) - :text (reply/get-quoted-text-with-mentions (:parsedText message))})))) + notification] + (let [chat-id (get-in notification [:body :chat :id])] + (or (= app-state "background") + (not (chat.models/foreground-chat? cofx chat-id))))) (defn create-notification ([notification] @@ -172,7 +106,7 @@ ([cofx {:keys [bodyType] :as notification}] (assoc (case bodyType - "message" (create-message-notification cofx notification) + "message" (when (show-message-pn? cofx notification) notification) "transaction" (create-transfer-notification notification) nil) :body-type bodyType))) @@ -194,16 +128,3 @@ (if platform/ios? {::local-push-ios evt} (local-notification-android cofx evt))) - -(defn handle [] - (fn [^js message] - (let [evt (types/json->clj (.-event message))] - (js/Promise. - (fn [on-success on-error] - (try - (when (= "local-notifications" (:type evt)) - (re-frame/dispatch [::local-notification-android (:event evt)])) - (on-success) - (catch :default e - (log/warn "failed to handle background notification" e) - (on-error e)))))))) diff --git a/src/status_im/router/core.cljs b/src/status_im/router/core.cljs index 3d257a4392..9e8aab1ae6 100644 --- a/src/status_im/router/core.cljs +++ b/src/status_im/router/core.cljs @@ -43,6 +43,7 @@ "b/" browser-extractor "browser/" browser-extractor ["p/" :chat-id] :private-chat + ["cr/" :community-id] :community-requests "g/" group-chat-extractor ["wallet/" :account] :wallet-account ["u/" :user-id] :user @@ -201,6 +202,9 @@ (spec/valid? :global/public-key uri) (match-contact-async chain {:user-id uri} cb) + (= handler :community-requests) + (cb {:type handler :community-id (:community-id route-params)}) + (= handler :referrals) (cb (match-referral route-params)) diff --git a/src/status_im/utils/universal_links/core.cljs b/src/status_im/utils/universal_links/core.cljs index 9dde2a6285..c652209b1a 100644 --- a/src/status_im/utils/universal_links/core.cljs +++ b/src/status_im/utils/universal_links/core.cljs @@ -24,11 +24,12 @@ (def domains {:external "https://join.status.im" :internal "status-im:/"}) -(def links {:public-chat "%s/%s" - :private-chat "%s/p/%s" - :group-chat "%s/g/%s" - :user "%s/u/%s" - :browse "%s/b/%s"}) +(def links {:public-chat "%s/%s" + :private-chat "%s/p/%s" + :community-requests "%s/cr/%s" + :group-chat "%s/g/%s" + :user "%s/u/%s" + :browse "%s/b/%s"}) (defn generate-link [link-type domain-type param] (gstring/format (get links link-type) @@ -59,6 +60,10 @@ {:utils/show-popup {:title (i18n/label :t/unable-to-read-this-code) :content (i18n/label :t/can-not-add-yourself)}}))) +(fx/defn handle-community-requests [cofx {:keys [community-id]}] + (log/info "universal-links: handling community request " community-id) + (navigation/navigate-to-cofx cofx :community-requests-to-join {:community-id community-id})) + (fx/defn handle-public-chat [cofx {:keys [topic]}] (log/info "universal-links: handling public chat" topic) (when (seq topic) @@ -113,20 +118,22 @@ {:events [::match-value]} [cofx url {:keys [type] :as data}] (case type - :group-chat (handle-group-chat cofx data) - :public-chat (handle-public-chat cofx data) - :private-chat (handle-private-chat cofx data) - :contact (handle-view-profile cofx data) - :browser (handle-browse cofx data) - :eip681 (handle-eip681 cofx data) - :referrals (handle-referrer-url cofx data) - :wallet-account (handle-wallet-account cofx data) + :group-chat (handle-group-chat cofx data) + :public-chat (handle-public-chat cofx data) + :private-chat (handle-private-chat cofx data) + :community-requests (handle-community-requests cofx data) + :contact (handle-view-profile cofx data) + :browser (handle-browse cofx data) + :eip681 (handle-eip681 cofx data) + :referrals (handle-referrer-url cofx data) + :wallet-account (handle-wallet-account cofx data) (handle-not-found url))) (fx/defn route-url "Match a url against a list of routes and handle accordingly" [{:keys [db]} url] {::router/handle-uri {:chain (ethereum/chain-keyword db) + :chats (:chats db) :uri url :cb #(re-frame/dispatch [::match-value url %])}}) diff --git a/status-go-version.json b/status-go-version.json index 18a8b9e41d..ce8b4da197 100644 --- a/status-go-version.json +++ b/status-go-version.json @@ -2,7 +2,7 @@ "_comment": "DO NOT EDIT THIS FILE BY HAND. USE 'scripts/update-status-go.sh ' instead", "owner": "status-im", "repo": "status-go", - "version": "v0.74.1", - "commit-sha1": "5a76e93063e3b9ef2ded83a5f545f7149401e8f0", - "src-sha256": "0581048fvqg0fxv03pm9a0drfiq32slxliwamnch3052avpdkycj" + "version": "v0.74.2", + "commit-sha1": "1724ecffa178e9bcb04ae6d9d95d276c02878b6f", + "src-sha256": "1kn6996brpva1bdj8bv1mscwx17hynqk8xb667ai6qpdriscr22d" } diff --git a/test/appium/tests/atomic/account_management/test_profile.py b/test/appium/tests/atomic/account_management/test_profile.py index b337af15aa..9518f402a4 100644 --- a/test/appium/tests/atomic/account_management/test_profile.py +++ b/test/appium/tests/atomic/account_management/test_profile.py @@ -1123,7 +1123,7 @@ class TestProfileMultipleDevice(MultipleDeviceTestCase): home_1.just_fyi('check that PN is received and after tap you are redirected to public chat') home_1.open_notification_bar() - home_1.element_by_text_part('%s in #%s' % (username_2, chat_name)).click() + home_1.element_by_text_part(username_2).click() chat_1.element_starts_with_text(user_1['ens'] +'.stateofus.eth','button').click() if not profile_1.contacts_button.is_element_displayed(): self.errors.append('Was not redirected to own profile after tapping on mention of myself from another user!') diff --git a/test/appium/tests/atomic/chats/test_group_chat.py b/test/appium/tests/atomic/chats/test_group_chat.py index 5154671ed9..cb688d747c 100644 --- a/test/appium/tests/atomic/chats/test_group_chat.py +++ b/test/appium/tests/atomic/chats/test_group_chat.py @@ -57,6 +57,7 @@ class TestGroupChatMultipleDevice(MultipleDeviceTestCase): for chat in (device_1_chat, device_2_chat): if not chat.chat_element_by_text(join_system_message).is_element_displayed(): self.errors.append('System message after joining group chat is not shown') + device_2_chat.home_button.click(desired_view="home") message_1 = "Message from device: %s" % device_1_chat.driver.number device_1_chat.send_message(message_1) if device_1_chat.chat_element_by_text(message_1).status != 'delivered': diff --git a/test/appium/tests/atomic/chats/test_one_to_one.py b/test/appium/tests/atomic/chats/test_one_to_one.py index 46665e6876..265a012ad2 100644 --- a/test/appium/tests/atomic/chats/test_one_to_one.py +++ b/test/appium/tests/atomic/chats/test_one_to_one.py @@ -360,7 +360,7 @@ class TestMessagesOneToOneChatMultiple(MultipleDeviceTestCase): chat_1.send_message_button.click() device_2.open_notification_bar() - chat_2 = home_2.click_upon_push_notification_by_text("audio message") + chat_2 = home_2.click_upon_push_notification_by_text("Audio") listen_time = 5