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
This commit is contained in:
yenda 2020-03-26 13:02:42 +01:00
parent 277f65b94d
commit 5608d649b0
No known key found for this signature in database
GPG Key ID: 0095623C0069DCE6
4 changed files with 151 additions and 69 deletions

View File

@ -17,4 +17,3 @@ SNOOPY=0
RPC_NETWORKS_ONLY=1 RPC_NETWORKS_ONLY=1
PARTITIONED_TOPIC=0 PARTITIONED_TOPIC=0
MOBILE_UI_FOR_DESKTOP=1 MOBILE_UI_FOR_DESKTOP=1
LOCAL_NOTIFICATIONS=0

View File

@ -3,6 +3,8 @@ package im.status.ethereum.module;
import android.content.Context; import android.content.Context;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.BroadcastReceiver;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
@ -25,6 +27,7 @@ import android.graphics.BitmapFactory;
import android.app.Notification; import android.app.Notification;
import android.app.NotificationChannel; import android.app.NotificationChannel;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.PendingIntent;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
@ -35,7 +38,14 @@ import android.media.AudioAttributes;
import android.util.Log; 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 GROUP_STATUS_MESSAGES = "im.status.notifications.message";
private static final String CHANNEL_NAME = "Status"; private static final String CHANNEL_NAME = "Status";
private static final String CHANNEL_DESCRIPTION = "Get notifications on new messages and mentions"; private static final String CHANNEL_DESCRIPTION = "Get notifications on new messages and mentions";
@ -48,6 +58,64 @@ class NewMessageSignalHandler {
private Intent serviceIntent; private Intent serviceIntent;
private Boolean shouldRefreshNotifications; 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) { public NewMessageSignalHandler(Context context) {
// NOTE: when instanciated the NewMessageSignalHandler class starts a foreground service // NOTE: when instanciated the NewMessageSignalHandler class starts a foreground service
// to keep the app running in the background in order to receive notifications // 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"); Log.e(TAG, "Starting Foreground Service");
Intent serviceIntent = new Intent(context, ForegroundService.class); Intent serviceIntent = new Intent(context, ForegroundService.class);
context.startService(serviceIntent); context.startService(serviceIntent);
this.registerBroadcastReceiver();
} }
public void stop() { public void stop() {
Log.e(TAG, "Stopping Foreground Service"); 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); Intent serviceIntent = new Intent(context, ForegroundService.class);
context.stopService(serviceIntent); context.stopService(serviceIntent);
context.unregisterReceiver(notificationActionReceiver);
} }
private void createNotificationChannel() { private void createNotificationChannel() {
@ -77,30 +150,49 @@ class NewMessageSignalHandler {
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH);
channel.setDescription(CHANNEL_DESCRIPTION); channel.setDescription(CHANNEL_DESCRIPTION);
AudioAttributes audioAttributes = new AudioAttributes.Builder() AudioAttributes audioAttributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION) .setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build(); .build();
channel.setSound(soundUri, audioAttributes); channel.setSound(soundUri, audioAttributes);
NotificationManager notificationManager = context.getSystemService(NotificationManager.class); NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.createNotificationChannel(channel); 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) { public void notify(int notificationId, StatusChat chat) {
NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle("Me"); NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle("Me");
ArrayList<StatusMessage> messages = chat.getMessages(); ArrayList<StatusMessage> messages = chat.getMessages();
for (int i = 0; i < messages.size(); i++) { for (int i = 0; i < messages.size(); i++) {
StatusMessage message = messages.get(i); StatusMessage message = messages.get(i);
messagingStyle.addMessage(message.getText(), messagingStyle.addMessage(message.getText(),
message.getTimestamp(), message.getTimestamp(),
message.getAuthor()); message.getAuthor());
} }
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_notify_status) .setSmallIcon(R.drawable.ic_stat_notify_status)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setStyle(messagingStyle) .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) { if (Build.VERSION.SDK_INT >= 21) {
builder.setVibrate(new long[0]); builder.setVibrate(new long[0]);
} }
@ -109,33 +201,13 @@ class NewMessageSignalHandler {
public void refreshNotifications() { public void refreshNotifications() {
NotificationCompat.InboxStyle summaryStyle = new NotificationCompat.InboxStyle(); 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 notificationId = 2; // we start at 2 because the service is using 1 and can't use 0
int messageCounter = 0;
Iterator<StatusChat> chatIterator = chats.values().iterator(); Iterator<StatusChat> chatIterator = chats.values().iterator();
while(chatIterator.hasNext()) { while(chatIterator.hasNext()) {
StatusChat chat = (StatusChat)chatIterator.next(); StatusChat chat = (StatusChat)chatIterator.next();
notify(notificationId, chat); notify(notificationId, chat);
notificationId++; 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) { void handleNewMessageSignal(JSONObject newMessageSignal) {
@ -207,7 +279,7 @@ class NewMessageSignalHandler {
chat = new StatusChat(id, true); chat = new StatusChat(id, true);
} }
chats.put(id, chat); chats.put(id, chat);
} }
} catch (JSONException e) { } catch (JSONException e) {
Log.e(TAG, "JSON conversion failed: " + e.getMessage()); Log.e(TAG, "JSON conversion failed: " + e.getMessage());
@ -217,34 +289,34 @@ class NewMessageSignalHandler {
private void upsertMessage(JSONObject messageData) { private void upsertMessage(JSONObject messageData) {
try { try {
String chatId = messageData.getString("localChatId"); String chatId = messageData.getString("localChatId");
StatusChat chat = chats.get(chatId); StatusChat chat = chats.get(chatId);
if (chat == null) { if (chat == null) {
return; return;
} }
StatusMessage message = createMessage(messageData); StatusMessage message = createMessage(messageData);
if (message != null) { if (message != null) {
chat.appendMessage(message); chat.appendMessage(message);
chats.put(chatId, chat); chats.put(chatId, chat);
shouldRefreshNotifications = true; shouldRefreshNotifications = true;
} }
} }
catch (JSONException e) { catch (JSONException e) {
Log.e(TAG, "JSON conversion failed: " + e.getMessage()); Log.e(TAG, "JSON conversion failed: " + e.getMessage());
} }
} }
private StatusMessage createMessage(JSONObject messageData) { private StatusMessage createMessage(JSONObject messageData) {
try { try {
Person author = getPerson(messageData.getString("from"), messageData.getString("identicon"), messageData.getString("alias")); Person author = getPerson(messageData.getString("from"), messageData.getString("identicon"), messageData.getString("alias"));
return new StatusMessage(author, messageData.getLong("whisperTimestamp"), messageData.getString("text")); return new StatusMessage(author, messageData.getLong("whisperTimestamp"), messageData.getString("text"));
} catch (JSONException e) { } catch (JSONException e) {
Log.e(TAG, "JSON conversion failed: " + e.getMessage()); Log.e(TAG, "JSON conversion failed: " + e.getMessage());
} }
return null; return null;
} }
} }
@ -267,22 +339,22 @@ class StatusChat {
public String getName() { public String getName() {
//TODO this should be improved as it would rename the chat //TODO this should be improved as it would rename the chat
// after our own user if we were posting from another device // after our own user if we were posting from another device
// in 1-1 chats it should be the name of the user whose // in 1-1 chats it should be the name of the user whose
// key is different than ours // key is different than ours
StatusMessage message = getLastMessage(); StatusMessage message = getLastMessage();
if (message == null) { if (message == null) {
return "no-name"; return "no-name";
} }
return message.getAuthor().getName().toString(); return message.getAuthor().getName().toString();
} }
private StatusMessage getLastMessage() { private StatusMessage getLastMessage() {
if (messages.size() > 0) { if (messages.size() > 0) {
return messages.get(messages.size()-1); return messages.get(messages.size()-1);
} }
return null; return null;
} }
public long getTimestamp() { public long getTimestamp() {
@ -294,7 +366,7 @@ class StatusChat {
} }
public void appendMessage(StatusMessage message) { public void appendMessage(StatusMessage message) {
this.messages.add(message); this.messages.add(message);
} }
public String getSummary() { public String getSummary() {

View File

@ -69,6 +69,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL
private ReactApplicationContext reactContext; private ReactApplicationContext reactContext;
private boolean rootedDevice; private boolean rootedDevice;
private NewMessageSignalHandler newMessageSignalHandler; private NewMessageSignalHandler newMessageSignalHandler;
private boolean background;
StatusModule(ReactApplicationContext reactContext, boolean rootedDevice) { StatusModule(ReactApplicationContext reactContext, boolean rootedDevice) {
super(reactContext); super(reactContext);
@ -85,17 +86,18 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL
@Override @Override
public void onHostResume() { // Activity `onResume` public void onHostResume() { // Activity `onResume`
module = this; module = this;
this.background = false;
Statusgo.setMobileSignalHandler(this); Statusgo.setMobileSignalHandler(this);
} }
@Override @Override
public void onHostPause() { public void onHostPause() {
this.background = true;
} }
@Override @Override
public void onHostDestroy() { public void onHostDestroy() {
Intent intent = new Intent(getReactApplicationContext(), ForegroundService.class); Log.d(TAG, "******************* ON HOST DESTROY *************************");
getReactApplicationContext().stopService(intent);
} }
@ReactMethod @ReactMethod
@ -145,7 +147,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL
Log.d(TAG, "Signal event: " + jsonEventString); Log.d(TAG, "Signal event: " + jsonEventString);
// NOTE: the newMessageSignalHandler is only instanciated if the user // NOTE: the newMessageSignalHandler is only instanciated if the user
// enabled notifications in the app // enabled notifications in the app
if (newMessageSignalHandler != null) { if (this.background && newMessageSignalHandler != null) {
if (eventType.equals("messages.new")) { if (eventType.equals("messages.new")) {
newMessageSignalHandler.handleNewMessageSignal(jsonEvent); newMessageSignalHandler.handleNewMessageSignal(jsonEvent);
} }

View File

@ -21,6 +21,7 @@
;; TODO(yenda) investigate why `handle-universal-link` event is ;; TODO(yenda) investigate why `handle-universal-link` event is
;; dispatched 7 times for the same link ;; 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 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 profile-regex #"(?:https?://join\.)?status[.-]im(?::/)?/(?:u/(0x.*)$|u/(.*)$|user/(.*))$")
(def browse-regex #"(?:https?://join\.)?status[.-]im(?::/)?/(?:b/(.*)$|browse/(.*))$") (def browse-regex #"(?:https?://join\.)?status[.-]im(?::/)?/(?:b/(.*)$|browse/(.*))$")
@ -30,6 +31,7 @@
:internal "status-im:/"}) :internal "status-im:/"})
(def links {:public-chat "%s/%s" (def links {:public-chat "%s/%s"
:private-chat "%s/p/%s"
:user "%s/u/%s" :user "%s/u/%s"
:browse "%s/b/%s"}) :browse "%s/b/%s"})
@ -71,6 +73,10 @@
(when (security/safe-link? url) (when (security/safe-link? url)
{:browser/show-browser-selection 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] (fx/defn handle-public-chat [cofx public-chat]
(log/info "universal-links: handling public chat" public-chat) (log/info "universal-links: handling public chat" public-chat)
(chat/start-public-chat cofx public-chat {})) (chat/start-public-chat cofx public-chat {}))
@ -120,6 +126,9 @@
[cofx url] [cofx url]
(cond (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)) (spec/valid? :global/public-key (match-url url profile-regex))
(handle-view-profile cofx {:public-key (match-url url profile-regex)}) (handle-view-profile cofx {:public-key (match-url url profile-regex)})