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
PARTITIONED_TOPIC=0
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.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<StatusMessage> 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<StatusChat> 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() {

View File

@ -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);
}

View File

@ -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)})