diff --git a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java index 3096522b6..c8cb0692c 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -71,23 +71,19 @@ public class CustomPushNotificationHelper { } long timestamp = new Date().getTime(); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - messagingStyle.addMessage(message, timestamp, senderName); - } else { - Person.Builder sender = new Person.Builder() - .setKey(senderId) - .setName(senderName); + Person.Builder sender = new Person.Builder() + .setKey(senderId) + .setName(senderName); - if (serverUrl != null) { - try { - sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, senderId)))); - } catch (IOException e) { - e.printStackTrace(); - } + if (serverUrl != null) { + try { + sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, senderId)))); + } catch (IOException e) { + e.printStackTrace(); } - - messagingStyle.addMessage(message, timestamp, sender.build()); } + + messagingStyle.addMessage(message, timestamp, sender.build()); } private static void addNotificationExtras(NotificationCompat.Builder notification, Bundle bundle) { @@ -111,6 +107,16 @@ public class CustomPushNotificationHelper { userInfoBundle.putString("root_id", rootId); } + String crtEnabled = bundle.getString("is_crt_enabled"); + if (crtEnabled != null) { + userInfoBundle.putString("is_crt_enabled", crtEnabled); + } + + String serverUrl = bundle.getString("server_url"); + if (serverUrl != null) { + userInfoBundle.putString("server_url", serverUrl); + } + notification.addExtras(userInfoBundle); } @@ -166,7 +172,7 @@ public class CustomPushNotificationHelper { String rootId = bundle.getString("root_id"); int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID; - Boolean is_crt_enabled = bundle.getString("is_crt_enabled") != null && bundle.getString("is_crt_enabled").equals("true"); + boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true"); String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId; addNotificationExtras(notification, bundle); @@ -256,24 +262,20 @@ public class CustomPushNotificationHelper { String senderId = "me"; final String serverUrl = bundle.getString("server_url"); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - messagingStyle = new NotificationCompat.MessagingStyle("Me"); - } else { - Person.Builder sender = new Person.Builder() - .setKey(senderId) - .setName("Me"); + Person.Builder sender = new Person.Builder() + .setKey(senderId) + .setName("Me"); - if (serverUrl != null) { - try { - sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, "me")))); - } catch (IOException e) { - e.printStackTrace(); - } + if (serverUrl != null) { + try { + sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, "me")))); + } catch (IOException e) { + e.printStackTrace(); } - - messagingStyle = new NotificationCompat.MessagingStyle(sender.build()); } + messagingStyle = new NotificationCompat.MessagingStyle(sender.build()); + String conversationTitle = getConversationTitle(bundle); setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle); addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle); diff --git a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt index 9b870140d..901f80a78 100644 --- a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt +++ b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt @@ -16,8 +16,7 @@ import java.lang.Exception import java.util.* class DatabaseHelper { - var defaultDatabase: Database? = null - private set + private var defaultDatabase: Database? = null val onlyServerUrl: String? get() { @@ -83,12 +82,15 @@ class DatabaseHelper { fun queryIds(db: Database, tableName: String, ids: Array): List { val list: MutableList = ArrayList() - val args = TextUtils.join(",", Arrays.stream(ids).map { value: String? -> "?" }.toArray()) + val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray()) try { db.rawQuery("select distinct id from $tableName where id IN ($args)", ids as Array).use { cursor -> if (cursor.count > 0) { while (cursor.moveToNext()) { - list.add(cursor.getString(cursor.getColumnIndex("id"))) + val index = cursor.getColumnIndex("id") + if (index >= 0) { + list.add(cursor.getString(index)) + } } } } @@ -100,12 +102,15 @@ class DatabaseHelper { fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array): List { val list: MutableList = ArrayList() - val args = TextUtils.join(",", Arrays.stream(values).map { value: Any? -> "?" }.toArray()) + val args = TextUtils.join(",", Arrays.stream(values).map { "?" }.toArray()) try { db.rawQuery("select distinct $columnName from $tableName where $columnName IN ($args)", values).use { cursor -> if (cursor.count > 0) { while (cursor.moveToNext()) { - list.add(cursor.getString(cursor.getColumnIndex(columnName))) + val index = cursor.getColumnIndex(columnName) + if (index >= 0) { + list.add(cursor.getString(index)) + } } } } @@ -120,7 +125,7 @@ class DatabaseHelper { return result.getString("value") } - fun queryLastPostCreateAt(db: Database?, channelId: String): Double? { + private fun queryLastPostCreateAt(db: Database?, channelId: String): Double? { if (db != null) { val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1" val cursor1 = db.rawQuery(postsInChannelQuery, arrayOf(channelId)) @@ -175,7 +180,7 @@ class DatabaseHelper { val firstId = ordered.first() val lastId = ordered.last() lastFetchedAt = postList.fold(0.0) { acc, next -> - val post = next.second as Map + val post = next.second as Map<*, *> val createAt = post["create_at"] as Double val updateAt = post["update_at"] as Double val deleteAt = post["delete_at"] as Double @@ -186,38 +191,38 @@ class DatabaseHelper { var prevPostId = "" val sortedPosts = postList.sortedBy { (_, value) -> - ((value as Map<*, *>).get("create_at") as Double) + ((value as Map<*, *>)["create_at"] as Double) } sortedPosts.forEachIndexed { index, it -> val key = it.first if (it.second != null) { - val post = (it.second as MutableMap) + val post = it.second as MutableMap if (index == 0) { post.putIfAbsent("prev_post_id", previousPostId) - } else if (!prevPostId.isNullOrEmpty()) { + } else if (prevPostId.isNotEmpty()) { post.putIfAbsent("prev_post_id", prevPostId) } if (lastId == key) { - earliest = post.get("create_at") as Double + earliest = post["create_at"] as Double } if (firstId == key) { - latest = post.get("create_at") as Double + latest = post["create_at"] as Double } val jsonPost = JSONObject(post) - val rootId = post.get("root_id") as? String + val rootId = post["root_id"] as? String if (!rootId.isNullOrEmpty()) { - var thread = postsInThread.get(rootId)?.toMutableList() + var thread = postsInThread[rootId]?.toMutableList() if (thread == null) { thread = mutableListOf() } thread.add(jsonPost) - postsInThread.put(rootId, thread.toList()) + postsInThread[rootId] = thread.toList() } if (find(db, "Post", key) == null) { @@ -308,27 +313,6 @@ class DatabaseHelper { } } - fun getServerVersion(db: Database): String? { - val config = getSystemConfig(db) - if (config != null) { - return config.getString("Version") - } - return null - } - - private fun getSystemConfig(db: Database): JSONObject? { - val configRecord = find(db, "System", "config") - if (configRecord != null) { - val value = configRecord.getString("value"); - try { - return JSONObject(value) - } catch(e: JSONException) { - return null - } - } - return null - } - private fun setDefaultDatabase(context: Context) { val databaseName = "app.db" val databasePath = Uri.fromFile(context.filesDir).toString() + "/" + databaseName @@ -336,7 +320,7 @@ class DatabaseHelper { } private fun insertPost(db: Database, post: JSONObject) { - var metadata: JSONObject? = null + var metadata: JSONObject? var reactions: JSONArray? = null var customEmojis: JSONArray? = null var files: JSONArray? = null @@ -390,7 +374,7 @@ class DatabaseHelper { } private fun updatePost(db: Database, post: JSONObject) { - var metadata: JSONObject? = null + var metadata: JSONObject? var reactions: JSONArray? = null var customEmojis: JSONArray? = null @@ -441,10 +425,12 @@ class DatabaseHelper { private fun insertThread(db: Database, thread: ReadableMap) { // These fields are not present when we extract threads from posts - val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { false }; - val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 }; - val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 }; - val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 }; + val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { false } + val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { 0 } + val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { 0 } + val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { 0 } + val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 } + val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 } db.execute( "insert into Thread " + @@ -452,9 +438,9 @@ class DatabaseHelper { " values (?, ?, 0, ?, ?, ?, ?, ?, 'created')", arrayOf( thread.getString("id"), - thread.getDouble("last_reply_at") ?: 0, + lastReplyAt, lastViewedAt, - thread.getInt("reply_count") ?: 0, + replyCount, isFollowing, unreadReplies, unreadMentions @@ -464,17 +450,19 @@ class DatabaseHelper { private fun updateThread(db: Database, thread: ReadableMap, existingRecord: ReadableMap) { // These fields are not present when we extract threads from posts - val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { existingRecord.getInt("is_following") == 1 }; - val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") }; - val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") }; - val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") }; + val isFollowing = try { thread.getBoolean("is_following") } catch (e: NoSuchKeyException) { existingRecord.getInt("is_following") == 1 } + val lastViewedAt = try { thread.getDouble("last_viewed_at") } catch (e: NoSuchKeyException) { existingRecord.getDouble("last_viewed_at") } + val unreadReplies = try { thread.getInt("unread_replies") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_replies") } + val unreadMentions = try { thread.getInt("unread_mentions") } catch (e: NoSuchKeyException) { existingRecord.getInt("unread_mentions") } + val lastReplyAt = try { thread.getDouble("last_reply_at") } catch (e: NoSuchKeyException) { 0 } + val replyCount = try { thread.getInt("reply_count") } catch (e: NoSuchKeyException) { 0 } db.execute( "update Thread SET last_reply_at = ?, last_viewed_at = ?, reply_count = ?, is_following = ?, unread_replies = ?, unread_mentions = ?, _status = 'updated' where id = ?", arrayOf( - thread.getDouble("last_reply_at") ?: 0, + lastReplyAt, lastViewedAt, - thread.getInt("reply_count") ?: 0, + replyCount, isFollowing, unreadReplies, unreadMentions, @@ -518,9 +506,9 @@ class DatabaseHelper { private fun insertFiles(db: Database, files: JSONArray) { for (i in 0 until files.length()) { val file = files.getJSONObject(i) - val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" }; - val height = try { file.getInt("height") } catch (e: JSONException) { 0 }; - val width = try { file.getInt("width") } catch (e: JSONException) { 0 }; + val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" } + val height = try { file.getInt("height") } catch (e: JSONException) { 0 } + val width = try { file.getInt("width") } catch (e: JSONException) { 0 } db.execute( "insert into File (id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _status) " + "values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, 'created')", @@ -604,11 +592,11 @@ class DatabaseHelper { for (i in 0 until chunks.size()) { val chunk = chunks.getMap(i) if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) { - return chunk; + return chunk } } - return null; + return null } private fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap { @@ -666,7 +654,7 @@ class DatabaseHelper { } } - private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { + private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { it -> when (val value = this[it]) { is JSONArray -> diff --git a/android/app/src/main/java/com/mattermost/helpers/KeysReadableArray.java b/android/app/src/main/java/com/mattermost/helpers/KeysReadableArray.java deleted file mode 100644 index 93ab0b820..000000000 --- a/android/app/src/main/java/com/mattermost/helpers/KeysReadableArray.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.mattermost.helpers; - -import com.facebook.react.bridge.Dynamic; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.ReadableType; - -import java.util.ArrayList; - -/** - * KeysReadableArray: Helper class that abstracts boilerplate - */ -public class KeysReadableArray implements ReadableArray { - @Override - public int size() { - return 0; - } - - @Override - public boolean isNull(int index) { - return false; - } - - @Override - public boolean getBoolean(int index) { - return false; - } - - @Override - public double getDouble(int index) { - return 0; - } - - @Override - public int getInt(int index) { - return 0; - } - - @Override - public String getString(int index) { - return null; - } - - @Override - public ReadableArray getArray(int index) { - return null; - } - - @Override - public ReadableMap getMap(int index) { - return null; - } - - @Override - public Dynamic getDynamic(int index) { - return null; - } - - @Override - public ReadableType getType(int index) { - return null; - } - - @Override - public ArrayList toArrayList() { - return null; - } -} diff --git a/android/app/src/main/java/com/mattermost/helpers/NotificationHelper.java b/android/app/src/main/java/com/mattermost/helpers/NotificationHelper.java new file mode 100644 index 000000000..a77eede9f --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/NotificationHelper.java @@ -0,0 +1,312 @@ +package com.mattermost.helpers; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.os.Bundle; +import android.service.notification.StatusBarNotification; + + +import androidx.core.app.NotificationManagerCompat; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; + +public class NotificationHelper { + public static final String PUSH_NOTIFICATIONS = "PUSH_NOTIFICATIONS"; + public static final String NOTIFICATIONS_IN_GROUP = "notificationsInGroup"; + private static final String VERSION_PREFERENCE = "VERSION_PREFERENCE"; + + public static void cleanNotificationPreferencesIfNeeded(Context context) { + try { + PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + String version = String.valueOf(pInfo.versionCode); + String storedVersion = null; + SharedPreferences pSharedPref = context.getSharedPreferences(VERSION_PREFERENCE, Context.MODE_PRIVATE); + if (pSharedPref != null) { + storedVersion = pSharedPref.getString("Version", ""); + } + + if (!version.equals(storedVersion)) { + if (pSharedPref != null) { + SharedPreferences.Editor editor = pSharedPref.edit(); + editor.putString("Version", version); + editor.apply(); + } + + Map inputMap = new HashMap<>(); + saveMap(context, inputMap); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + public static int getNotificationId(Bundle notification) { + final String postId = notification.getString("post_id"); + final String channelId = notification.getString("channel_id"); + + int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID; + if (postId != null) { + notificationId = postId.hashCode(); + } else if (channelId != null) { + notificationId = channelId.hashCode(); + } + + return notificationId; + } + + public static StatusBarNotification[] getDeliveredNotifications(Context context) { + final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + return notificationManager.getActiveNotifications(); + } + + public static boolean addNotificationToPreferences(Context context, int notificationId, Bundle notification) { + try { + boolean createSummary = true; + final String serverUrl = notification.getString("server_url"); + final String channelId = notification.getString("channel_id"); + final String rootId = notification.getString("root_id"); + final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true"); + + final boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId); + final String groupId = isThreadNotification ? rootId : channelId; + + Map notificationsPerServer = loadMap(context); + JSONObject notificationsInServer = notificationsPerServer.get(serverUrl); + if (notificationsInServer == null) { + notificationsInServer = new JSONObject(); + } + + JSONObject notificationsInGroup = notificationsInServer.optJSONObject(groupId); + if (notificationsInGroup == null) { + notificationsInGroup = new JSONObject(); + } + + if (notificationsInGroup.length() > 0) { + createSummary = false; + } + + notificationsInGroup.put(String.valueOf(notificationId), false); + + if (createSummary) { + // Add the summary notification id as well + notificationsInGroup.put(String.valueOf(notificationId + 1), true); + } + notificationsInServer.put(groupId, notificationsInGroup); + notificationsPerServer.put(serverUrl, notificationsInServer); + saveMap(context, notificationsPerServer); + + return createSummary; + } catch(Exception e) { + e.printStackTrace(); + return false; + } + } + + public static void dismissNotification(Context context, Bundle notification) { + final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true"); + final String serverUrl = notification.getString("server_url"); + final String channelId = notification.getString("channel_id"); + final String rootId = notification.getString("root_id"); + + int notificationId = getNotificationId(notification); + + if (!android.text.TextUtils.isEmpty(serverUrl) && !android.text.TextUtils.isEmpty(channelId)) { + boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId); + String notificationIdStr = String.valueOf(notificationId); + String groupId = isThreadNotification ? rootId : channelId; + + Map notificationsPerServer = loadMap(context); + JSONObject notificationsInServer = notificationsPerServer.get(serverUrl); + if (notificationsInServer == null) { + return; + } + + JSONObject notificationsInGroup = notificationsInServer.optJSONObject(groupId); + if (notificationsInGroup == null) { + return; + } + boolean isSummary = notificationsInGroup.optBoolean(notificationIdStr); + notificationsInGroup.remove(notificationIdStr); + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.cancel(notificationId); + StatusBarNotification[] statusNotifications = getDeliveredNotifications(context); + boolean hasMore = false; + + for (final StatusBarNotification status : statusNotifications) { + Bundle bundle = status.getNotification().extras; + if (isThreadNotification) { + hasMore = bundle.getString("root_id").equals(rootId); + } else { + hasMore = bundle.getString("channel_id").equals(channelId); + } + if (hasMore) break; + } + + if (!hasMore || isSummary) { + notificationsInServer.remove(groupId); + } else { + try { + notificationsInServer.put(groupId, notificationsInGroup); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + notificationsPerServer.put(serverUrl, notificationsInServer); + saveMap(context, notificationsPerServer); + } + } + + public static void removeChannelNotifications(Context context, String serverUrl, String channelId) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + Map notificationsPerServer = loadMap(context); + JSONObject notificationsInServer = notificationsPerServer.get(serverUrl); + + if (notificationsInServer != null) { + notificationsInServer.remove(channelId); + notificationsPerServer.put(serverUrl, notificationsInServer); + saveMap(context, notificationsPerServer); + } + + StatusBarNotification[] notifications = getDeliveredNotifications(context); + for (StatusBarNotification sbn:notifications) { + Notification n = sbn.getNotification(); + Bundle bundle = n.extras; + String cId = bundle.getString("channel_id"); + String rootId = bundle.getString("root_id"); + boolean isCRTEnabled = bundle.containsKey("is_crt_enabled") && bundle.getString("is_crt_enabled").equals("true"); + boolean skipThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId); + if (Objects.equals(cId, channelId) && !skipThreadNotification) { + notificationManager.cancel(sbn.getId()); + } + } + } + + public static void removeThreadNotifications(Context context, String serverUrl, String threadId) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + Map notificationsPerServer = loadMap(context); + JSONObject notificationsInServer = notificationsPerServer.get(serverUrl); + + StatusBarNotification[] notifications = getDeliveredNotifications(context); + for (StatusBarNotification sbn:notifications) { + Notification n = sbn.getNotification(); + Bundle bundle = n.extras; + String rootId = bundle.getString("root_id"); + String postId = bundle.getString("post_id"); + if (Objects.equals(rootId, threadId)) { + notificationManager.cancel(sbn.getId()); + } + + if (Objects.equals(postId, threadId)) { + String channelId = bundle.getString("channel_id"); + int id = sbn.getId(); + if (notificationsInServer != null && channelId != null) { + JSONObject notificationsInChannel = notificationsInServer.optJSONObject(channelId); + if (notificationsInChannel != null) { + notificationsInChannel.remove(String.valueOf(id)); + try { + notificationsInServer.put(channelId, notificationsInChannel); + } catch (JSONException e) { + e.printStackTrace(); + } + } + } + notificationManager.cancel(id); + } + } + + if (notificationsInServer != null) { + notificationsInServer.remove(threadId); + notificationsPerServer.put(serverUrl, notificationsInServer); + saveMap(context, notificationsPerServer); + } + } + + public static void removeServerNotifications(Context context, String serverUrl) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + Map notificationsPerServer = loadMap(context); + notificationsPerServer.remove(serverUrl); + saveMap(context, notificationsPerServer); + StatusBarNotification[] notifications = getDeliveredNotifications(context); + for (StatusBarNotification sbn:notifications) { + Notification n = sbn.getNotification(); + Bundle bundle = n.extras; + String url = bundle.getString("server_url"); + if (Objects.equals(url, serverUrl)) { + notificationManager.cancel(sbn.getId()); + } + } + } + + public static void clearChannelOrThreadNotifications(Context context, Bundle notification) { + final String serverUrl = notification.getString("server_url"); + final String channelId = notification.getString("channel_id"); + final String rootId = notification.getString("root_id"); + if (channelId != null) { + final boolean isCRTEnabled = notification.containsKey("is_crt_enabled") && notification.getString("is_crt_enabled").equals("true"); + // rootId is available only when CRT is enabled & clearing the thread + final boolean isClearThread = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId); + + if (isClearThread) { + removeThreadNotifications(context, serverUrl, rootId); + } else { + removeChannelNotifications(context, serverUrl, channelId); + } + } + } + + + /** + * Map Structure + * + * { serverUrl: { groupId: { notification1: true, notification2: false } } } + * summary notification has a value of true + * + */ + + private static void saveMap(Context context, Map inputMap) { + SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE); + if (pSharedPref != null) { + JSONObject json = new JSONObject(inputMap); + String jsonString = json.toString(); + SharedPreferences.Editor editor = pSharedPref.edit(); + editor.remove(NOTIFICATIONS_IN_GROUP).apply(); + editor.putString(NOTIFICATIONS_IN_GROUP, jsonString); + editor.apply(); + } + } + + private static Map loadMap(Context context) { + Map outputMap = new HashMap<>(); + if (context != null) { + SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE); + try { + if (pSharedPref != null) { + String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_GROUP, (new JSONObject()).toString()); + JSONObject json = new JSONObject(jsonString); + Iterator servers = json.keys(); + + while (servers.hasNext()) { + String serverUrl = servers.next(); + JSONObject notificationGroup = json.getJSONObject(serverUrl); + outputMap.put(serverUrl, notificationGroup); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + return outputMap; + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt b/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt index 9a61616b0..1b183661b 100644 --- a/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt +++ b/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt @@ -2,6 +2,7 @@ package com.mattermost.helpers import android.content.Context import android.os.Bundle +import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap @@ -25,7 +26,8 @@ class PushNotificationDataHelper(private val context: Context) { class PushNotificationDataRunnable { companion object { - private val specialMentions = listOf("all", "here", "channel") + private val specialMentions = listOf("all", "here", "channel") + @Synchronized suspend fun start(context: Context, initialData: Bundle) { try { @@ -34,6 +36,7 @@ class PushNotificationDataRunnable { val rootId = initialData.getString("root_id") val isCRTEnabled = initialData.getString("is_crt_enabled") == "true" val db = DatabaseHelper.instance!!.getDatabaseForServer(context, serverUrl) + Log.i("ReactNative", "Start fetching notification data in server="+serverUrl+" for channel="+channelId) if (db != null) { var postData: ReadableMap? @@ -90,6 +93,7 @@ class PushNotificationDataRunnable { } db.close() + Log.i("ReactNative", "Done processing push notification="+serverUrl+" for channel="+channelId) } } catch (e: Exception) { e.printStackTrace() @@ -108,14 +112,13 @@ class PushNotificationDataRunnable { additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true" } - var endpoint: String val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty() - if (receivingThreads) { - var queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up" - endpoint = "/api/v4/posts/$rootId/thread$queryParams$additionalParams" + val endpoint = if (receivingThreads) { + val queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up" + "/api/v4/posts/$rootId/thread$queryParams$additionalParams" } else { - var queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}" - endpoint = "/api/v4/channels/$channelId/posts$queryParams$additionalParams" + val queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}" + "/api/v4/channels/$channelId/posts$queryParams$additionalParams" } val postsResponse = fetch(serverUrl, endpoint) @@ -124,11 +127,11 @@ class PushNotificationDataRunnable { if (postsResponse != null) { val data = ReadableMapUtils.toMap(postsResponse) results.putMap("posts", postsResponse) - val postsData = data.get("data") as? Map<*, *> + val postsData = data["data"] as? Map<*, *> if (postsData != null) { - val postsMap = postsData.get("posts") + val postsMap = postsData["posts"] if (postsMap != null) { - val posts = ReadableMapUtils.toWritableMap(postsMap as? Map) + val posts = ReadableMapUtils.toWritableMap(postsMap as? Map) val iterator = posts.keySetIterator() val userIds = mutableListOf() val usernames = mutableListOf() @@ -158,8 +161,8 @@ class PushNotificationDataRunnable { if (isCRTEnabled) { // Add root post as a thread - val rootId = post?.getString("root_id") - if (rootId.isNullOrEmpty()) { + val threadId = post?.getString("root_id") + if (threadId.isNullOrEmpty()) { threads.pushMap(post!!) } @@ -169,14 +172,14 @@ class PushNotificationDataRunnable { for (i in 0 until participants.size()) { val participant = participants.getMap(i) - val userId = participant.getString("id") - if (userId != currentUserId && userId != null) { - if (!threadParticipantUserIds.contains(userId)) { - threadParticipantUserIds.add(userId) + val participantId = participant.getString("id") + if (participantId != currentUserId && participantId != null) { + if (!threadParticipantUserIds.contains(participantId)) { + threadParticipantUserIds.add(participantId) } - if (!threadParticipantUsers.containsKey(userId)) { - threadParticipantUsers[userId!!] = participant + if (!threadParticipantUsers.containsKey(participantId)) { + threadParticipantUsers[participantId] = participant } } @@ -236,14 +239,14 @@ class PushNotificationDataRunnable { val endpoint = "api/v4/users/ids" val options = Arguments.createMap() options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds))) - return fetchWithPost(serverUrl, endpoint, options); + return fetchWithPost(serverUrl, endpoint, options) } private suspend fun fetchUsersByUsernames(serverUrl: String, usernames: ReadableArray): ReadableMap? { val endpoint = "api/v4/users/usernames" val options = Arguments.createMap() options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(usernames))) - return fetchWithPost(serverUrl, endpoint, options); + return fetchWithPost(serverUrl, endpoint, options) } private suspend fun fetch(serverUrl: String, endpoint: String): ReadableMap? { diff --git a/android/app/src/main/java/com/mattermost/helpers/RandomId.kt b/android/app/src/main/java/com/mattermost/helpers/RandomId.kt index bebe1f495..7795d0a29 100644 --- a/android/app/src/main/java/com/mattermost/helpers/RandomId.kt +++ b/android/app/src/main/java/com/mattermost/helpers/RandomId.kt @@ -5,15 +5,15 @@ import kotlin.math.floor class RandomId { companion object { private const val alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" - private const val alphabetLenght = alphabet.length - private const val idLenght = 16 + private const val alphabetLength = alphabet.length + private const val idLength = 16 fun generate(): String { var id = "" - for (i in 1.rangeTo((idLenght / 2))) { - val random = floor(Math.random() * alphabetLenght * alphabetLenght) - id += alphabet[floor(random / alphabetLenght).toInt()] - id += alphabet[(random % alphabetLenght).toInt()] + for (i in 1.rangeTo((idLength / 2))) { + val random = floor(Math.random() * alphabetLength * alphabetLength) + id += alphabet[floor(random / alphabetLength).toInt()] + id += alphabet[(random % alphabetLength).toInt()] } return id diff --git a/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java b/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java index eb2e473c8..0da230fdf 100644 --- a/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java +++ b/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java @@ -99,23 +99,17 @@ public class ReadableArrayUtils { for (Object value : array) { if (value == null) { writableArray.pushNull(); - } - if (value instanceof Boolean) { + } else if (value instanceof Boolean) { writableArray.pushBoolean((Boolean) value); - } - if (value instanceof Double) { + } else if (value instanceof Double) { writableArray.pushDouble((Double) value); - } - if (value instanceof Integer) { + } else if (value instanceof Integer) { writableArray.pushInt((Integer) value); - } - if (value instanceof String) { + } else if (value instanceof String) { writableArray.pushString((String) value); - } - if (value instanceof Map) { + } else if (value instanceof Map) { writableArray.pushMap(ReadableMapUtils.toWritableMap((Map) value)); - } - if (value.getClass().isArray()) { + } else if (value.getClass().isArray()) { writableArray.pushArray(ReadableArrayUtils.toWritableArray((Object[]) value)); } } diff --git a/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java b/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java index 4a25b5012..a6c71ff07 100644 --- a/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java +++ b/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java @@ -1,6 +1,7 @@ package com.mattermost.helpers; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.ReadableType; @@ -38,10 +39,16 @@ public class ReadableMapUtils { jsonObject.put(key, readableMap.getString(key)); break; case Map: - jsonObject.put(key, ReadableMapUtils.toJSONObject(readableMap.getMap(key))); + ReadableMap map = readableMap.getMap(key); + if (map != null) { + jsonObject.put(key, ReadableMapUtils.toJSONObject(map)); + } break; case Array: - jsonObject.put(key, ReadableArrayUtils.toJSONArray(readableMap.getArray(key))); + ReadableArray array = readableMap.getArray(key); + if (array != null) { + jsonObject.put(key, ReadableArrayUtils.toJSONArray(array)); + } break; } } @@ -92,10 +99,16 @@ public class ReadableMapUtils { map.put(key, readableMap.getString(key)); break; case Map: - map.put(key, ReadableMapUtils.toMap(readableMap.getMap(key))); + ReadableMap obj = readableMap.getMap(key); + if (obj != null) { + map.put(key, ReadableMapUtils.toMap(obj)); + } break; case Array: - map.put(key, ReadableArrayUtils.toArray(readableMap.getArray(key))); + ReadableArray array = readableMap.getArray(key); + if (array != null) { + map.put(key, ReadableArrayUtils.toArray(array)); + } break; } } @@ -105,26 +118,26 @@ public class ReadableMapUtils { public static WritableMap toWritableMap(Map map) { WritableMap writableMap = Arguments.createMap(); - Iterator iterator = map.entrySet().iterator(); + Iterator> iterator = map.entrySet().iterator(); while (iterator.hasNext()) { - Map.Entry pair = (Map.Entry)iterator.next(); + Map.Entry pair = iterator.next(); Object value = pair.getValue(); if (value == null) { - writableMap.putNull((String) pair.getKey()); + writableMap.putNull(pair.getKey()); } else if (value instanceof Boolean) { - writableMap.putBoolean((String) pair.getKey(), (Boolean) value); + writableMap.putBoolean(pair.getKey(), (Boolean) value); } else if (value instanceof Double) { - writableMap.putDouble((String) pair.getKey(), (Double) value); + writableMap.putDouble(pair.getKey(), (Double) value); } else if (value instanceof Integer) { - writableMap.putInt((String) pair.getKey(), (Integer) value); + writableMap.putInt(pair.getKey(), (Integer) value); } else if (value instanceof String) { - writableMap.putString((String) pair.getKey(), (String) value); - } else if (value instanceof Map) { - writableMap.putMap((String) pair.getKey(), ReadableMapUtils.toWritableMap((Map) value)); - } else if (value.getClass() != null && value.getClass().isArray()) { - writableMap.putArray((String) pair.getKey(), ReadableArrayUtils.toWritableArray((Object[]) value)); + writableMap.putString(pair.getKey(), (String) value); + } else if (value instanceof Map) + writableMap.putMap(pair.getKey(), ReadableMapUtils.toWritableMap((Map) value)); + else if (value.getClass().isArray()) { + writableMap.putArray(pair.getKey(), ReadableArrayUtils.toWritableArray((Object[]) value)); } iterator.remove(); diff --git a/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java b/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java index ed651701c..a17025f48 100644 --- a/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java +++ b/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java @@ -3,11 +3,9 @@ package com.mattermost.helpers; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import android.os.Build; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.provider.OpenableColumns; -import android.content.ContentResolver; import android.os.Environment; import android.webkit.MimeTypeMap; import android.util.Log; @@ -18,16 +16,14 @@ import android.os.ParcelFileDescriptor; import java.io.*; import java.nio.channels.FileChannel; -// Class based on the steveevers DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3 +// Class based on DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3 public class RealPathUtil { public static final String CACHE_DIR_NAME = "mmShare"; public static String getRealPathFromURI(final Context context, final Uri uri) { - final boolean isKitKatOrNewer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; - // DocumentProvider - if (isKitKatOrNewer && DocumentsContract.isDocumentUri(context, uri)) { + if (DocumentsContract.isDocumentUri(context, uri)) { // ExternalStorageProvider if (isExternalStorageDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); @@ -48,7 +44,7 @@ public class RealPathUtil { try { return getPathFromSavingTempFile(context, uri); } catch (NumberFormatException e) { - Log.e("ReactNative", "DownloadsProvider unexpected uri " + uri.toString()); + Log.e("ReactNative", "DownloadsProvider unexpected uri " + uri); return null; } } @@ -100,7 +96,7 @@ public class RealPathUtil { public static String getPathFromSavingTempFile(Context context, final Uri uri) { File tmpFile; - String fileName = null; + String fileName = ""; if (uri == null || uri.isRelative()) { return null; @@ -113,13 +109,14 @@ public class RealPathUtil { int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); returnCursor.moveToFirst(); fileName = sanitizeFilename(returnCursor.getString(nameIndex)); + returnCursor.close(); } catch (Exception e) { // just continue to get the filename with the last segment of the path } try { if (TextUtils.isEmpty(fileName)) { - fileName = sanitizeFilename(uri.getLastPathSegment().toString().trim()); + fileName = sanitizeFilename(uri.getLastPathSegment().trim()); } @@ -128,7 +125,6 @@ public class RealPathUtil { cacheDir.mkdirs(); } - String mimeType = getMimeType(uri.getPath()); tmpFile = new File(cacheDir, fileName); tmpFile.createNewFile(); @@ -214,15 +210,6 @@ public class RealPathUtil { return getMimeType(file); } - public static String getMimeTypeFromUri(final Context context, final Uri uri) { - try { - ContentResolver cR = context.getContentResolver(); - return cR.getType(uri); - } catch (Exception e) { - return "application/octet-stream"; - } - } - public static void deleteTempFiles(final File dir) { try { if (dir.isDirectory()) { @@ -234,9 +221,13 @@ public class RealPathUtil { } private static void deleteRecursive(File fileOrDirectory) { - if (fileOrDirectory.isDirectory()) - for (File child : fileOrDirectory.listFiles()) - deleteRecursive(child); + if (fileOrDirectory.isDirectory()) { + File[] files = fileOrDirectory.listFiles(); + if (files != null) { + for (File child : files) + deleteRecursive(child); + } + } fileOrDirectory.delete(); } diff --git a/android/app/src/main/java/com/mattermost/helpers/ResolvePromise.java b/android/app/src/main/java/com/mattermost/helpers/ResolvePromise.java index cf93b7fa6..0c17b246e 100644 --- a/android/app/src/main/java/com/mattermost/helpers/ResolvePromise.java +++ b/android/app/src/main/java/com/mattermost/helpers/ResolvePromise.java @@ -1,5 +1,7 @@ package com.mattermost.helpers; +import androidx.annotation.NonNull; + import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.WritableMap; @@ -18,7 +20,7 @@ public class ResolvePromise implements Promise { } @Override - public void reject(String code, WritableMap map) { + public void reject(String code, @NonNull WritableMap map) { } @@ -48,7 +50,7 @@ public class ResolvePromise implements Promise { } @Override - public void reject(String code, String message, WritableMap map) { + public void reject(String code, String message, @NonNull WritableMap map) { } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java index 1a919ff10..61a1a53a7 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -1,30 +1,21 @@ package com.mattermost.rnbeta; import android.app.Notification; -import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.SharedPreferences; import android.os.Bundle; -import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.Log; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; import java.util.Objects; import com.mattermost.helpers.CustomPushNotificationHelper; import com.mattermost.helpers.DatabaseHelper; import com.mattermost.helpers.Network; +import com.mattermost.helpers.NotificationHelper; import com.mattermost.helpers.PushNotificationDataHelper; import com.mattermost.helpers.ResolvePromise; import com.wix.reactnativenotifications.core.NotificationIntentAdapter; @@ -34,15 +25,10 @@ import com.wix.reactnativenotifications.core.AppLifecycleFacade; import com.wix.reactnativenotifications.core.JsIOHelper; import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME; -import org.json.JSONObject; - public class CustomPushNotification extends PushNotification { - private static final String PUSH_NOTIFICATIONS = "PUSH_NOTIFICATIONS"; - private static final String VERSION_PREFERENCE = "VERSION_PREFERENCE"; private static final String PUSH_TYPE_MESSAGE = "message"; private static final String PUSH_TYPE_CLEAR = "clear"; private static final String PUSH_TYPE_SESSION = "session"; - private static final String NOTIFICATIONS_IN_CHANNEL = "notificationsInChannel"; private final PushNotificationDataHelper dataHelper; public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) { @@ -53,101 +39,12 @@ public class CustomPushNotification extends PushNotification { try { Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context); Network.init(context); - PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); - String version = String.valueOf(pInfo.versionCode); - String storedVersion = null; - SharedPreferences pSharedPref = context.getSharedPreferences(VERSION_PREFERENCE, Context.MODE_PRIVATE); - if (pSharedPref != null) { - storedVersion = pSharedPref.getString("Version", ""); - } - - if (!version.equals(storedVersion)) { - if (pSharedPref != null) { - SharedPreferences.Editor editor = pSharedPref.edit(); - editor.putString("Version", version); - editor.apply(); - } - - Map> inputMap = new HashMap<>(); - saveNotificationsMap(context, inputMap); - } - } catch (PackageManager.NameNotFoundException e) { + NotificationHelper.cleanNotificationPreferencesIfNeeded(context); + } catch (Exception e) { e.printStackTrace(); } } - public static void cancelNotification(Context context, String channelId, String rootId, Integer notificationId, Boolean isCRTEnabled) { - if (!android.text.TextUtils.isEmpty(channelId)) { - final String notificationIdStr = notificationId.toString(); - final Boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId); - final String groupId = isThreadNotification ? rootId : channelId; - Map> notificationsInChannel = loadNotificationsMap(context); - Map notifications = notificationsInChannel.get(groupId); - if (notifications == null) { - return; - } - - final NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - notificationManager.cancel(notificationId); - notifications.remove(notificationIdStr); - final StatusBarNotification[] statusNotifications = notificationManager.getActiveNotifications(); - boolean hasMore = false; - for (final StatusBarNotification status : statusNotifications) { - Bundle bundle = status.getNotification().extras; - if (isThreadNotification) { - hasMore = bundle.getString("root_id").equals(rootId); - } else { - hasMore = bundle.getString("channel_id").equals(channelId); - } - if (hasMore) { - break; - } - } - - if (!hasMore) { - notificationsInChannel.remove(groupId); - } else { - notificationsInChannel.put(groupId, notifications); - } - - saveNotificationsMap(context, notificationsInChannel); - } - } - - public static void clearChannelNotifications(Context context, String channelId, String rootId, Boolean isCRTEnabled) { - if (!android.text.TextUtils.isEmpty(channelId)) { - final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - - // rootId is available only when CRT is enabled & clearing the thread - final boolean isClearThread = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId); - - Map> notificationsInChannel = loadNotificationsMap(context); - String groupId = isClearThread ? rootId : channelId; - Map notifications = notificationsInChannel.get(groupId); - - if (notifications == null) { - return; - } - - notificationsInChannel.remove(groupId); - saveNotificationsMap(context, notificationsInChannel); - - notifications.forEach( - (notificationIdStr, post) -> notificationManager.cancel(Integer.valueOf(notificationIdStr)) - ); - } - } - - public static void clearAllNotifications(Context context) { - if (context != null) { - Map> notificationsInChannel = loadNotificationsMap(context); - notificationsInChannel.clear(); - saveNotificationsMap(context, notificationsInChannel); - final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - notificationManager.cancelAll(); - } - } - @Override public void onReceived() { final Bundle initialData = mNotificationProps.asBundle(); @@ -155,15 +52,8 @@ public class CustomPushNotification extends PushNotification { final String ackId = initialData.getString("ack_id"); final String postId = initialData.getString("post_id"); final String channelId = initialData.getString("channel_id"); - final String rootId = initialData.getString("root_id"); - final boolean isCRTEnabled = initialData.getString("is_crt_enabled") != null && initialData.getString("is_crt_enabled").equals("true"); final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true"); - int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID; - if (postId != null) { - notificationId = postId.hashCode(); - } else if (channelId != null) { - notificationId = channelId.hashCode(); - } + int notificationId = NotificationHelper.getNotificationId(initialData); String serverUrl = addServerUrlToBundle(initialData); boolean isReactInit = mAppLifecycleFacade.isReactInitialized(); @@ -176,7 +66,9 @@ public class CustomPushNotification extends PushNotification { Bundle response = (Bundle) value; if (value != null) { response.putString("server_url", serverUrl); - mNotificationProps = createProps(response); + Bundle current = mNotificationProps.asBundle(); + current.putAll(response); + mNotificationProps = createProps(current); } } } @@ -191,52 +83,24 @@ public class CustomPushNotification extends PushNotification { switch (type) { case PUSH_TYPE_MESSAGE: case PUSH_TYPE_SESSION: - boolean createSummary = type.equals(PUSH_TYPE_MESSAGE); if (!mAppLifecycleFacade.isAppVisible()) { + boolean createSummary = type.equals(PUSH_TYPE_MESSAGE); if (type.equals(PUSH_TYPE_MESSAGE)) { if (channelId != null) { + Bundle notificationBundle = mNotificationProps.asBundle(); if (serverUrl != null && !isReactInit) { // We will only fetch the data related to the notification on the native side // as updating the data directly to the db removes the wal & shm files needed // by watermelonDB, if the DB is updated while WDB is running it causes WDB to // detect the database as malformed, thus the app stop working and a restart is required. // Data will be fetch from within the JS context instead. - dataHelper.fetchAndStoreDataForPushNotification(mNotificationProps.asBundle()); - } - try { - JSONObject post = new JSONObject(); - if (!android.text.TextUtils.isEmpty(rootId)) { - post.put("root_id", rootId); - } - if (!android.text.TextUtils.isEmpty(postId)) { - post.put("post_id", postId); - } - - final Boolean isThreadNotification = isCRTEnabled && post.has("root_id"); - final String groupId = isThreadNotification ? rootId : channelId; - - Map> notificationsInChannel = loadNotificationsMap(mContext); - Map notifications = notificationsInChannel.get(groupId); - if (notifications == null) { - notifications = Collections.synchronizedMap(new HashMap()); - } - - if (notifications.size() > 0) { - createSummary = false; - } - - notifications.put(String.valueOf(notificationId), post); - - if (createSummary) { - // Add the summary notification id as well - notifications.put(String.valueOf(notificationId + 1), new JSONObject()); - } - - notificationsInChannel.put(groupId, notifications); - saveNotificationsMap(mContext, notificationsInChannel); - } catch(Exception e) { - e.printStackTrace(); + dataHelper.fetchAndStoreDataForPushNotification(notificationBundle); } + createSummary = NotificationHelper.addNotificationToPreferences( + mContext, + notificationId, + notificationBundle + ); } } @@ -244,7 +108,7 @@ public class CustomPushNotification extends PushNotification { } break; case PUSH_TYPE_CLEAR: - clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled); + NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle()); break; } @@ -255,15 +119,11 @@ public class CustomPushNotification extends PushNotification { @Override public void onOpened() { - digestNotification(); + if (mNotificationProps != null) { + digestNotification(); - Bundle data = mNotificationProps.asBundle(); - final String channelId = data.getString("channel_id"); - final String rootId = data.getString("root_id"); - final Boolean isCRTEnabled = data.getBoolean("is_crt_enabled"); - - if (channelId != null) { - clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled); + Bundle data = mNotificationProps.asBundle(); + NotificationHelper.clearChannelOrThreadNotifications(mContext, data); } } @@ -312,61 +172,4 @@ public class CustomPushNotification extends PushNotification { return serverUrl; } - - private static void saveNotificationsMap(Context context, Map> inputMap) { - SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE); - if (pSharedPref != null) { - JSONObject json = new JSONObject(inputMap); - String jsonString = json.toString(); - SharedPreferences.Editor editor = pSharedPref.edit(); - editor.remove(NOTIFICATIONS_IN_CHANNEL).apply(); - editor.putString(NOTIFICATIONS_IN_CHANNEL, jsonString); - editor.apply(); - } - } - - /** - * Map Structure - * - * { - * channel_id1 | thread_id1: { - * notification_id1: { - * post_id: 'p1', - * root_id: 'r1', - * } - * } - * } - * - */ - private static Map> loadNotificationsMap(Context context) { - Map> outputMap = new HashMap<>(); - if (context != null) { - SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE); - try { - if (pSharedPref != null) { - String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_CHANNEL, (new JSONObject()).toString()); - JSONObject json = new JSONObject(jsonString); - - // Can be a channel_id or thread_id - Iterator groupIdsItr = json.keys(); - while (groupIdsItr.hasNext()) { - String groupId = groupIdsItr.next(); - JSONObject notificationsJSONObj = json.getJSONObject(groupId); - Map notifications = new HashMap<>(); - Iterator notificationIdKeys = notificationsJSONObj.keys(); - while(notificationIdKeys.hasNext()) { - String notificationId = notificationIdKeys.next(); - JSONObject post = notificationsJSONObj.getJSONObject(notificationId); - notifications.put(notificationId, post); - } - outputMap.put(groupId, notifications); - } - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - return outputMap; - } } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java deleted file mode 100644 index 1fb95912d..000000000 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.mattermost.rnbeta; - -import android.content.Context; - -import com.wix.reactnativenotifications.core.AppLaunchHelper; -import com.wix.reactnativenotifications.core.notificationdrawer.PushNotificationsDrawer; - -public class CustomPushNotificationDrawer extends PushNotificationsDrawer { - final protected Context mContext; - final protected AppLaunchHelper mAppLaunchHelper; - - protected CustomPushNotificationDrawer(Context context, AppLaunchHelper appLaunchHelper) { - super(context, appLaunchHelper); - mContext = context; - mAppLaunchHelper = appLaunchHelper; - } - - @Override - public void onAppInit() { - } - - @Override - public void onAppVisible() { - } - - @Override - public void onNotificationOpened() { - } - - @Override - public void onCancelAllLocalNotifications() { - CustomPushNotification.clearAllNotifications(mContext); - cancelAllScheduledNotifications(); - } -} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java index 24179d2b7..596225b5b 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java @@ -1,13 +1,12 @@ package com.mattermost.rnbeta; import com.facebook.react.bridge.JSIModuleSpec; -import com.facebook.react.bridge.JavaScriptContextHolder; import android.content.Context; import android.os.Bundle; import android.util.Log; import java.io.File; -import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,10 +39,9 @@ import com.facebook.soloader.SoLoader; import com.mattermost.networkclient.RCTOkHttpClientFactory; import com.mattermost.newarchitecture.MainApplicationReactNativeHost; -import com.swmansion.reanimated.ReanimatedJSIModulePackage; import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage; -public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication { +public class MainApplication extends NavigationApplication implements INotificationsApplication { public static MainApplication instance; public Boolean sharedExtensionIsOpened = false; @@ -70,8 +68,8 @@ public class MainApplication extends NavigationApplication implements INotificat switch (name) { case "MattermostManaged": return MattermostManagedModule.getInstance(reactContext); - case "NotificationPreferences": - return NotificationPreferencesModule.getInstance(instance, reactContext); + case "Notifications": + return NotificationsModule.getInstance(instance, reactContext); default: throw new IllegalArgumentException("Could not find module " + name); } @@ -82,7 +80,7 @@ public class MainApplication extends NavigationApplication implements INotificat return () -> { Map map = new HashMap<>(); map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false)); - map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false)); + map.put("Notifications", new ReactModuleInfo("Notifications", "com.mattermost.rnbeta.NotificationsModule", false, false, false, false, false)); return map; }; } @@ -94,18 +92,11 @@ public class MainApplication extends NavigationApplication implements INotificat @Override protected JSIModulePackage getJSIModulePackage() { - return new JSIModulePackage() { - @Override - public List getJSIModules( - final ReactApplicationContext reactApplicationContext, - final JavaScriptContextHolder jsContext - ) { - List modules = Arrays.asList(); - modules.addAll(new WatermelonDBJSIPackage().getJSIModules(reactApplicationContext, jsContext)); - modules.addAll(new ReanimatedJSIModulePackage().getJSIModules(reactApplicationContext, jsContext)); + return (reactApplicationContext, jsContext) -> { + List modules = Collections.emptyList(); + modules.addAll(new WatermelonDBJSIPackage().getJSIModules(reactApplicationContext, jsContext)); - return modules; - } + return modules; }; } @@ -161,11 +152,6 @@ public class MainApplication extends NavigationApplication implements INotificat ); } - @Override - public IPushNotificationsDrawer getPushNotificationsDrawer(Context context, AppLaunchHelper defaultAppLaunchHelper) { - return new CustomPushNotificationDrawer(context, defaultAppLaunchHelper); - } - /** * Loads Flipper in React Native templates. Call this in the onCreate method with something like * initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); diff --git a/android/app/src/main/java/com/mattermost/rnbeta/NotificationDismissService.java b/android/app/src/main/java/com/mattermost/rnbeta/NotificationDismissService.java index f770914c7..3299145ee 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/NotificationDismissService.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/NotificationDismissService.java @@ -6,7 +6,7 @@ import android.app.IntentService; import android.os.Bundle; import android.util.Log; -import com.mattermost.helpers.CustomPushNotificationHelper; +import com.mattermost.helpers.NotificationHelper; import com.wix.reactnativenotifications.core.NotificationIntentAdapter; public class NotificationDismissService extends IntentService { @@ -18,19 +18,8 @@ public class NotificationDismissService extends IntentService { protected void onHandleIntent(Intent intent) { final Context context = getApplicationContext(); final Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent); - final String channelId = bundle.getString("channel_id"); - final String postId = bundle.getString("post_id"); - final String rootId = bundle.getString("root_id"); - final Boolean isCRTEnabled = bundle.getString("is_crt_enabled") != null && bundle.getString("is_crt_enabled").equals("true"); - int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID; - if (postId != null) { - notificationId = postId.hashCode(); - } else if (channelId != null) { - notificationId = channelId.hashCode(); - } - - CustomPushNotification.cancelNotification(context, channelId, rootId, notificationId, isCRTEnabled); + NotificationHelper.dismissNotification(context, bundle); Log.i("ReactNative", "Dismiss notification"); } } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/NotificationPreferencesModule.java b/android/app/src/main/java/com/mattermost/rnbeta/NotificationPreferencesModule.java deleted file mode 100644 index 59511d585..000000000 --- a/android/app/src/main/java/com/mattermost/rnbeta/NotificationPreferencesModule.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.mattermost.rnbeta; - -import android.app.Notification; -import android.app.NotificationManager; -import android.content.Context; -import android.os.Bundle; -import android.service.notification.StatusBarNotification; - -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.WritableArray; -import com.facebook.react.bridge.WritableMap; - -public class NotificationPreferencesModule extends ReactContextBaseJavaModule { - private static NotificationPreferencesModule instance; - private final MainApplication mApplication; - - private NotificationPreferencesModule(MainApplication application, ReactApplicationContext reactContext) { - super(reactContext); - mApplication = application; - Context context = mApplication.getApplicationContext(); - } - - public static NotificationPreferencesModule getInstance(MainApplication application, ReactApplicationContext reactContext) { - if (instance == null) { - instance = new NotificationPreferencesModule(application, reactContext); - } - - return instance; - } - - public static NotificationPreferencesModule getInstance() { - return instance; - } - - @Override - public String getName() { - return "NotificationPreferences"; - } - - @ReactMethod - public void getDeliveredNotifications(final Promise promise) { - final Context context = mApplication.getApplicationContext(); - final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - StatusBarNotification[] statusBarNotifications = notificationManager.getActiveNotifications(); - WritableArray result = Arguments.createArray(); - for (StatusBarNotification sbn:statusBarNotifications) { - WritableMap map = Arguments.createMap(); - Notification n = sbn.getNotification(); - Bundle bundle = n.extras; - String postId = bundle.getString("post_id"); - map.putString("post_id", postId); - String rootId = bundle.getString("root_id"); - map.putString("root_id", rootId); - String channelId = bundle.getString("channel_id"); - map.putString("channel_id", channelId); - result.pushMap(map); - } - promise.resolve(result); - } - - @ReactMethod - public void removeDeliveredNotifications(String channelId, String rootId, Boolean isCRTEnabled) { - Context context = mApplication.getApplicationContext(); - CustomPushNotification.clearChannelNotifications(context, channelId, rootId, isCRTEnabled); - } -} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java b/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java new file mode 100644 index 000000000..65c8cd2eb --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java @@ -0,0 +1,80 @@ +package com.mattermost.rnbeta; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Bundle; +import android.service.notification.StatusBarNotification; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.mattermost.helpers.NotificationHelper; + +import java.util.Set; + +public class NotificationsModule extends ReactContextBaseJavaModule { + private static NotificationsModule instance; + private final MainApplication mApplication; + + private NotificationsModule(MainApplication application, ReactApplicationContext reactContext) { + super(reactContext); + mApplication = application; + } + + public static NotificationsModule getInstance(MainApplication application, ReactApplicationContext reactContext) { + if (instance == null) { + instance = new NotificationsModule(application, reactContext); + } + + return instance; + } + + @NonNull + @Override + public String getName() { + return "Notifications"; + } + + @ReactMethod + public void getDeliveredNotifications(final Promise promise) { + Context context = mApplication.getApplicationContext(); + StatusBarNotification[] notifications = NotificationHelper.getDeliveredNotifications(context); + WritableArray result = Arguments.createArray(); + for (StatusBarNotification sbn:notifications) { + WritableMap map = Arguments.createMap(); + Notification n = sbn.getNotification(); + Bundle bundle = n.extras; + Set keys = bundle.keySet(); + for (String key: keys) { + map.putString(key, bundle.getString(key)); + } + result.pushMap(map); + } + promise.resolve(result); + } + + @ReactMethod + public void removeChannelNotifications(String serverUrl, String channelId) { + Context context = mApplication.getApplicationContext(); + NotificationHelper.removeChannelNotifications(context, serverUrl, channelId); + } + + @ReactMethod + public void removeThreadNotifications(String serverUrl, String threadId) { + Context context = mApplication.getApplicationContext(); + NotificationHelper.removeThreadNotifications(context, serverUrl, threadId); + } + + @ReactMethod + public void removeServerNotifications(String serverUrl) { + Context context = mApplication.getApplicationContext(); + NotificationHelper.removeServerNotifications(context, serverUrl); + } +} diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 4aff65d79..5c2de684b 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -17,7 +17,6 @@ import { import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId} from '@queries/servers/system'; import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team'; -import {getIsCRTEnabled} from '@queries/servers/thread'; import {getCurrentUser, queryUsersById} from '@queries/servers/user'; import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; @@ -176,8 +175,7 @@ export async function markChannelAsViewed(serverUrl: string, channelId: string, m.viewedAt = member.lastViewedAt; m.lastViewedAt = Date.now(); }); - const isCRTEnabled = await getIsCRTEnabled(database); - PushNotifications.cancelChannelNotifications(channelId, undefined, isCRTEnabled); + PushNotifications.removeChannelNotifications(serverUrl, channelId); if (!prepareRecordsOnly) { await operator.batchRecords([member]); } diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts index c02cc4d9f..44a4d2bbc 100644 --- a/app/actions/remote/entry/notification.ts +++ b/app/actions/remote/entry/notification.ts @@ -111,6 +111,8 @@ const restNotificationEntry = async (serverUrl: string, teamId: string, channelI const isCRTEnabled = await getIsCRTEnabled(database); const isThreadNotification = isCRTEnabled && Boolean(rootId); + await operator.batchRecords(models); + let switchedToScreen = false; let switchedToChannel = false; if (myChannel && myTeam) { @@ -129,15 +131,15 @@ const restNotificationEntry = async (serverUrl: string, teamId: string, channelI // Make switch again to get the missing data and make sure the team is the correct one switchedToScreen = true; if (isThreadNotification) { - fetchAndSwitchToThread(serverUrl, rootId, true); + await fetchAndSwitchToThread(serverUrl, rootId, true); } else { switchedToChannel = true; - switchToChannelById(serverUrl, selectedChannelId, selectedTeamId); + await switchToChannelById(serverUrl, selectedChannelId, selectedTeamId); } } else if (selectedTeamId !== teamId || selectedChannelId !== channelId) { // If in the end the selected team or channel is different than the one from the notification // we switch again - setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId); + await setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId); } } @@ -147,13 +149,19 @@ const restNotificationEntry = async (serverUrl: string, teamId: string, channelI emitNotificationError('Channel'); } - await operator.batchRecords(models); - const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!; const {config, license} = await getCommonSystemValues(operator.database); const lastDisconnectedAt = await getWebSocketLastDisconnected(database); - await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, switchedToChannel ? selectedChannelId : undefined); + + // Waiting for the screen to display fixes a race condition when fetching and storing data + if (switchedToChannel) { + await NavigationStore.waitUntilScreenHasLoaded(Screens.CHANNEL); + } else if (switchedToScreen && isThreadNotification) { + await NavigationStore.waitUntilScreenHasLoaded(Screens.THREAD); + } + + await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, selectedChannelId); return {userId: currentUserId}; }; diff --git a/app/actions/remote/notifications.ts b/app/actions/remote/notifications.ts index ebdfed663..f813ffb27 100644 --- a/app/actions/remote/notifications.ts +++ b/app/actions/remote/notifications.ts @@ -5,6 +5,7 @@ import {Platform} from 'react-native'; import {updatePostSinceCache, updatePostsInThreadsSinceCache} from '@actions/local/notification'; import {fetchDirectChannelsInfo, fetchMyChannel, switchToChannelById} from '@actions/remote/channel'; +import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {fetchMyTeam} from '@actions/remote/team'; import {fetchAndSwitchToThread} from '@actions/remote/thread'; @@ -73,6 +74,18 @@ const fetchNotificationData = async (serverUrl: string, notification: Notificati } } + if (Platform.OS === 'android') { + // on Android we only fetched the post data on the native side + // when the RN context is not running, thus we need to fetch the + // data here as well + const isCRTEnabled = await getIsCRTEnabled(database); + const isThreadNotification = isCRTEnabled && Boolean(notification.payload?.root_id); + if (isThreadNotification) { + fetchPostThread(serverUrl, notification.payload!.root_id!); + } else { + fetchPostsForChannel(serverUrl, channelId); + } + } return {}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index f7a4a9fb6..fa7c20755 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -54,7 +54,7 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string, } } - switchToThread(serverUrl, rootId, isFromNotification); + await switchToThread(serverUrl, rootId, isFromNotification); return {}; }; @@ -186,7 +186,11 @@ export const markThreadAsRead = async (serverUrl: string, teamId: string | undef const isCRTEnabled = await getIsCRTEnabled(database); const post = await getPostById(database, threadId); if (post) { - PushNotifications.cancelChannelNotifications(post.channelId, threadId, isCRTEnabled); + if (isCRTEnabled) { + PushNotifications.removeThreadNotifications(serverUrl, threadId); + } else { + PushNotifications.removeChannelNotifications(serverUrl, post.channelId); + } } return {data}; diff --git a/app/database/subscription/unreads.ts b/app/database/subscription/unreads.ts index 4604992a7..469004caf 100644 --- a/app/database/subscription/unreads.ts +++ b/app/database/subscription/unreads.ts @@ -35,12 +35,13 @@ export const subscribeServerUnreadAndMentions = (serverUrl: string, observer: Un let subscription: Subscription|undefined; if (server?.database) { - subscription = server.database.get(MY_CHANNEL). + subscription = server.database. + get(MY_CHANNEL). query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). observeWithColumns(['is_unread', 'mentions_count']). pipe( combineLatestWith(observeAllMyChannelNotifyProps(server.database)), - combineLatestWith(observeUnreadsAndMentionsInTeam(server.database, undefined, false)), + combineLatestWith(observeUnreadsAndMentionsInTeam(server.database, undefined, true)), map$(([[myChannels, settings], {unreads, mentions}]) => ({myChannels, settings, threadUnreads: unreads, threadMentionCount: mentions})), ). subscribe(observer); @@ -59,7 +60,7 @@ export const subscribeMentionsByServer = (serverUrl: string, observer: ServerUnr query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))). observeWithColumns(['mentions_count']). pipe( - combineLatestWith(observeThreadMentionCount(server.database, undefined, false)), + combineLatestWith(observeThreadMentionCount(server.database, undefined, true)), map$(([myChannels, threadMentionCount]) => ({myChannels, threadMentionCount})), ). subscribe(observer.bind(undefined, serverUrl)); @@ -78,7 +79,7 @@ export const subscribeUnreadAndMentionsByServer = (serverUrl: string, observer: observeWithColumns(['mentions_count', 'is_unread']). pipe( combineLatestWith(observeAllMyChannelNotifyProps(server.database)), - combineLatestWith(observeUnreadsAndMentionsInTeam(server.database, undefined, false)), + combineLatestWith(observeUnreadsAndMentionsInTeam(server.database, undefined, true)), map$(([[myChannels, settings], {unreads, mentions}]) => ({myChannels, settings, threadUnreads: unreads, threadMentionCount: mentions})), ). subscribe(observer.bind(undefined, serverUrl)); diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index 29759d77f..315bd47b8 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -20,7 +20,6 @@ import {backgroundNotification, openNotification} from '@actions/remote/notifica import {markThreadAsRead} from '@actions/remote/thread'; import {Device, Events, Navigation, Screens} from '@constants'; import DatabaseManager from '@database/manager'; -import {getTotalMentionsForServer} from '@database/subscription/unreads'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; import NativeNotifications from '@notifications'; import {queryServerName} from '@queries/app/servers'; @@ -52,60 +51,6 @@ class PushNotifications { Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground); } - cancelAllLocalNotifications = () => { - Notifications.cancelAllLocalNotifications(); - }; - - cancelChannelNotifications = async (channelId: string, rootId?: string, isCRTEnabled?: boolean) => { - const notifications = await NativeNotifications.getDeliveredNotifications(); - this.cancelNotificationsForChannel(notifications, channelId, rootId, isCRTEnabled); - }; - - cancelChannelsNotifications = async (channelIds: string[]) => { - const notifications = await NativeNotifications.getDeliveredNotifications(); - for (const channelId of channelIds) { - this.cancelNotificationsForChannel(notifications, channelId); - } - }; - - cancelNotificationsForChannel = (notifications: NotificationWithChannel[], channelId: string, rootId?: string, isCRTEnabled?: boolean) => { - if (Platform.OS === 'android') { - NativeNotifications.removeDeliveredNotifications(channelId, rootId, isCRTEnabled); - } else { - const ids: string[] = []; - const clearThreads = Boolean(rootId); - - for (const notification of notifications) { - if (notification.channel_id === channelId) { - let doesNotificationMatch = true; - if (clearThreads) { - doesNotificationMatch = notification.thread === rootId; - } else if (isCRTEnabled) { - // Do not match when CRT is enabled BUT post is not a root post - doesNotificationMatch = !notification.root_id; - } - if (doesNotificationMatch) { - ids.push(notification.identifier); - } - } - } - - if (ids.length) { - NativeNotifications.removeDeliveredNotifications(ids); - } - - let badgeCount = notifications.length - ids.length; - - const serversUrl = Object.keys(DatabaseManager.serverDatabases); - const mentionPromises = serversUrl.map((url) => getTotalMentionsForServer(url)); - Promise.all(mentionPromises).then((result) => { - badgeCount += result.reduce((acc, count) => (acc + count), 0); - badgeCount = badgeCount <= 0 ? 0 : badgeCount; - Notifications.ios.setBadgeCount(badgeCount); - }); - } - }; - createReplyCategory = () => { const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title')); const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button')); @@ -288,6 +233,18 @@ class PushNotifications { return null; }; + removeChannelNotifications = async (serverUrl: string, channelId: string) => { + NativeNotifications.removeChannelNotifications(serverUrl, channelId); + }; + + removeServerNotifications = (serverUrl: string) => { + NativeNotifications.removeServerNotifications(serverUrl); + }; + + removeThreadNotifications = async (serverUrl: string, threadId: string) => { + NativeNotifications.removeThreadNotifications(serverUrl, threadId); + }; + requestNotificationReplyPermissions = () => { if (Platform.OS === 'ios') { const replyCategory = this.createReplyCategory(); diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index 029f1b98d..c75561403 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -5,7 +5,6 @@ import CookieManager, {Cookie} from '@react-native-cookies/cookies'; import {Alert, DeviceEventEmitter, Linking, Platform} from 'react-native'; import semver from 'semver'; -import {selectAllMyChannelIds} from '@actions/local/channel'; import LocalConfig from '@assets/config.json'; import {Events, Sso, Launch} from '@constants'; import DatabaseManager from '@database/manager'; @@ -92,8 +91,7 @@ class GlobalEventHandler { onLogout = async ({serverUrl, removeServer}: LogoutCallbackArg) => { await removeServerCredentials(serverUrl); - const channelIds = await selectAllMyChannelIds(serverUrl); - PushNotifications.cancelChannelsNotifications(channelIds); + PushNotifications.removeServerNotifications(serverUrl); NetworkManager.invalidateClient(serverUrl); WebsocketManager.invalidateClient(serverUrl); diff --git a/app/notifications/index.ts b/app/notifications/index.ts index 8eff48aa5..8688b7056 100644 --- a/app/notifications/index.ts +++ b/app/notifications/index.ts @@ -1,5 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// @ts-expect-error platform specific -export {default} from './notifications'; +import {NativeModules} from 'react-native'; + +const {Notifications} = NativeModules; + +const nativeNotification: NativeNotification = { + getDeliveredNotifications: Notifications.getDeliveredNotifications, + removeChannelNotifications: Notifications.removeChannelNotifications, + removeThreadNotifications: Notifications.removeThreadNotifications, + removeServerNotifications: Notifications.removeServerNotifications, +}; + +export default nativeNotification; diff --git a/app/notifications/notifications.android.ts b/app/notifications/notifications.android.ts deleted file mode 100644 index e7a8e127b..000000000 --- a/app/notifications/notifications.android.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {NativeModules, PermissionsAndroid} from 'react-native'; - -const {NotificationPreferences} = NativeModules; - -const defaultPreferences: NativeNotificationPreferences = { - sounds: [], - shouldBlink: false, - shouldVibrate: true, -}; - -const nativeNotification: NativeNotification = { - getDeliveredNotifications: NotificationPreferences.getDeliveredNotifications, - getPreferences: async () => { - try { - const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); - let granted; - if (!hasPermission) { - granted = await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, - ); - } - - if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) { - return await NotificationPreferences.getPreferences(); - } - - return defaultPreferences; - } catch (error) { - return defaultPreferences; - } - }, - play: NotificationPreferences.previewSound, - removeDeliveredNotifications: NotificationPreferences.removeDeliveredNotifications, - setNotificationSound: NotificationPreferences.setNotificationSound, - setShouldBlink: NotificationPreferences.setShouldBlink, - setShouldVibrate: NotificationPreferences.setShouldVibrate, -}; - -export default nativeNotification; diff --git a/app/notifications/notifications.ios.ts b/app/notifications/notifications.ios.ts deleted file mode 100644 index e8517c373..000000000 --- a/app/notifications/notifications.ios.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Notifications} from 'react-native-notifications'; - -import {emptyFunction} from '@utils/general'; - -const nativeNotification: NativeNotification = { - getDeliveredNotifications: async () => Notifications.ios.getDeliveredNotifications(), - getPreferences: async () => null, - play: (soundUri: string) => emptyFunction(soundUri), - removeDeliveredNotifications: async (ids: string[]) => Notifications.ios.removeDeliveredNotifications(ids), - setNotificationSound: () => emptyFunction(), - setShouldBlink: (shouldBlink: boolean) => emptyFunction(shouldBlink), - setShouldVibrate: (shouldVibrate: boolean) => emptyFunction(shouldVibrate), -}; - -export default nativeNotification; diff --git a/app/screens/home/tab_bar/home.tsx b/app/screens/home/tab_bar/home.tsx index f74b00c1d..9d0982116 100644 --- a/app/screens/home/tab_bar/home.tsx +++ b/app/screens/home/tab_bar/home.tsx @@ -2,13 +2,15 @@ // See LICENSE.txt for license information. import React, {useEffect, useState} from 'react'; -import {StyleSheet, View} from 'react-native'; +import {Platform, StyleSheet, View} from 'react-native'; +import {Notifications} from 'react-native-notifications'; import Badge from '@components/badge'; import CompassIcon from '@components/compass_icon'; import {BOTTOM_TAB_ICON_SIZE} from '@constants/view'; import {subscribeAllServers} from '@database/subscription/servers'; import {subscribeUnreadAndMentionsByServer, UnreadObserverArgs} from '@database/subscription/unreads'; +import NativeNotification from '@notifications'; import {changeOpacity} from '@utils/theme'; import type ServersModel from '@typings/database/models/app/servers'; @@ -48,6 +50,16 @@ const Home = ({isFocused, theme}: Props) => { mentions += value.mentions; }); setTotal({mentions, unread}); + + if (Platform.OS === 'ios') { + NativeNotification.getDeliveredNotifications().then((delivered) => { + if (mentions === 0 && delivered.length > 0) { + return; + } + + Notifications.ios.setBadgeCount(mentions); + }); + } }; const unreadsSubscription = (serverUrl: string, {myChannels, settings, threadMentionCount}: UnreadObserverArgs) => { diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Mentions.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Mentions.swift new file mode 100644 index 000000000..831d34b31 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Mentions.swift @@ -0,0 +1,64 @@ +import Foundation + +public struct ChannelMemberData: Codable { + let channel_id: String + let mention_count: Int + let mention_count_root: Int + let user_id: String + let roles: String + let last_viewed_at: Int64 + let last_update_at: Int64 + + public enum ChannelMemberKeys: String, CodingKey { + case channel_id, mention_count, mention_count_root, user_id, roles, last_viewed_at, last_update_at + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ChannelMemberKeys.self) + channel_id = try container.decode(String.self, forKey: .channel_id) + mention_count = try container.decode(Int.self, forKey: .mention_count) + let mentions_root = try? container.decode(Int?.self, forKey: .mention_count_root) ?? 0 + mention_count_root = mentions_root! + user_id = try container.decode(String.self, forKey: .user_id) + roles = try container.decode(String.self, forKey: .roles) + last_update_at = try container.decode(Int64.self, forKey: .last_update_at) + last_viewed_at = try container.decode(Int64.self, forKey: .last_viewed_at) + } +} + +public struct ThreadData: Codable { + let id: String + let last_reply_at: Int64 + let last_viewed_at: Int64 + let reply_count: Int + let unread_replies: Int + let unread_mentions: Int + + public enum ThreadKeys: String, CodingKey { + case id, last_reply_at, last_viewed_at, reply_count, unread_replies, unread_mentions + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ThreadKeys.self) + id = try container.decode(String.self, forKey: .id) + last_viewed_at = try container.decode(Int64.self, forKey: .last_viewed_at) + last_reply_at = try container.decode(Int64.self, forKey: .last_reply_at) + reply_count = try container.decode(Int.self, forKey: .reply_count) + unread_replies = try container.decode(Int.self, forKey: .unread_replies) + unread_mentions = try container.decode(Int.self, forKey: .unread_mentions) + } +} + +extension Network { + public func fetchChannelMentions(channelId: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + let endpoint = "/channels/\(channelId)/members/me" + let url = buildApiUrl(serverUrl, endpoint) + return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler) + } + + public func fetchThreadMentions(teamId: String, threadId: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + let endpoint = "/users/me/teams/\(teamId)/threads/\(threadId)" + let url = buildApiUrl(serverUrl, endpoint) + return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift index 008ba2729..5bf0f6ff4 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift @@ -108,7 +108,6 @@ extension Network { } public func fetchAndStoreDataForPushNotification(_ notification: UNMutableNotificationContent, withContentHandler contentHandler: ((UNNotificationContent) -> Void)?) { - let operation = BlockOperation { let group = DispatchGroup() @@ -121,11 +120,38 @@ extension Network { let currentUsername = currentUser?[Expression("username")] var postData: PostData? = nil + var myChannelData: ChannelMemberData? = nil + var threadData: ThreadData? = nil var threads: [Post] = [] var userIdsToLoad: Set = Set() var usernamesToLoad: Set = Set() var users: Set = Set() + if isCRTEnabled && !rootId.isEmpty { + // Fetch the thread mentions + let teamId = Gekidou.Database.default.queryTeamIdForChannel(withId: channelId, withServerUrl: serverUrl) ?? "" + + if !teamId.isEmpty { + group.enter() + self.fetchThreadMentions(teamId: teamId, threadId: rootId, withServerUrl: serverUrl, completionHandler: {data, response, error in + if self.responseOK(response), let data = data { + threadData = try? JSONDecoder().decode(ThreadData.self, from: data) + } + group.leave() + }) + } + } else { + // Fetch the channel mentions + group.enter() + self.fetchChannelMentions(channelId: channelId, withServerUrl: serverUrl, completionHandler: { data, response, error in + if self.responseOK(response), let data = data { + myChannelData = try? JSONDecoder().decode(ChannelMemberData.self, from: data) + } + group.leave() + }) + } + + group.enter() let since = try? Database.default.queryPostsSinceForChannel(withId: channelId, withServerUrl: serverUrl) self.fetchPostsForChannel(withId: channelId, withSince: since, withServerUrl: serverUrl, withIsCRTEnabled: isCRTEnabled, withRootId: rootId) { data, response, error in @@ -242,18 +268,30 @@ extension Network { let receivingThreads = isCRTEnabled && !rootId.isEmpty try? db.transaction { try? Database.default.handlePostData(db, postData!, channelId, since != nil, receivingThreads) - if (threads.count > 0) { + + if threads.count > 0 { try? Database.default.handleThreads(db, threads) } - if (users.count > 0) { + + if users.count > 0 { try? Database.default.insertUsers(db, users) } + + if myChannelData != nil { + try? Database.default.handleMyChannelMentions(db, myChannelData!, withCRTEnabled: isCRTEnabled) + } + + if threadData != nil { + try? Database.default.handleThreadMentions(db, threadData!) + } } } } group.leave() if let contentHandler = contentHandler { + // Get the total mentions from all databases and set the badge icon + notification.badge = Gekidou.Database.default.getTotalMentions() as NSNumber contentHandler(notification) } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift index 33bfd1bd2..6e5c16f27 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift @@ -147,7 +147,7 @@ extension Database { let onlyDMs = term.starts(with: "@") ? "AND c.type = 'D'" : "" var username: String = "" var displayName: String = "" - var searchTerm = term.removePrefix("@").lowercased() + let searchTerm = term.removePrefix("@").lowercased() var bindings: [String] = [] if matchStart { username = "u.username LIKE ?" diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift new file mode 100644 index 000000000..6c8ca3b50 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift @@ -0,0 +1,159 @@ +import Foundation +import SQLite + +extension Database { + public func hasMyChannel(_ db: Connection, channelId: String) -> Bool { + let idCol = Expression("id") + let query = myChannelTable.where(idCol == channelId) + if let _ = try? db.pluck(query) { + return true + } + + return false + } + + public func hasThread(_ db: Connection, threadId: String) -> Bool { + let idCol = Expression("id") + let query = threadTable.where(idCol == threadId) + if let _ = try? db.pluck(query) { + return true + } + + return false + } + + public func getTotalMentions() -> Int { + let serverUrls = getAllActiveServerUrls() + var mentions = 0 + for serverUrl in serverUrls { + if let db = try? getDatabaseForServer(serverUrl) { + mentions += (getChannelMentions(db) + getThreadMentions(db)) + } + } + + return mentions + } + + public func getChannelMentions(_ db: Connection) -> Int { + let mentionsCol = Expression("mentions_count") + let mentions = try? db.scalar(myChannelTable.select(mentionsCol.total)) + return Int(mentions ?? 0) + } + + public func getThreadMentions(_ db: Connection) -> Int { + let mentionsCol = Expression("unread_mentions") + let mentions = try? db.scalar(threadTable.select(mentionsCol.total)) + return Int(mentions ?? 0) + } + + public func handleMyChannelMentions(_ db: Connection, _ channelMemberData: ChannelMemberData, withCRTEnabled crtEnabled: Bool) throws { + let idCol = Expression("id") + let mentionsCol = Expression("mentions_count") + let isUnreadCol = Expression("is_unread") + let mentions = crtEnabled ? channelMemberData.mention_count_root : channelMemberData.mention_count + + if hasMyChannel(db, channelId: channelMemberData.channel_id) { + let updateQuery = myChannelTable + .where(idCol == channelMemberData.channel_id) + .update(mentionsCol <- mentions, + isUnreadCol <- true + ) + let _ = try db.run(updateQuery) + } else { + let msgCol = Expression("message_count") + let lastPostAtCol = Expression("last_post_at") + let lastViewedAtCol = Expression("last_viewed_at") + let viewedAtCol = Expression("viewed_at") + let lastFetchedAtCol = Expression("last_fetched_at") + let manuallyUnreadCol = Expression("manually_unread") + let rolesCol = Expression("roles") + let statusCol = Expression("status") + + let setters: [Setter] = [ + idCol <- channelMemberData.channel_id, + mentionsCol <- mentions, + msgCol <- mentions, + lastPostAtCol <- channelMemberData.last_update_at, + lastViewedAtCol <- channelMemberData.last_viewed_at, + viewedAtCol <- 0, + lastFetchedAtCol <- 0, + isUnreadCol <- true, + manuallyUnreadCol <- false, + rolesCol <- channelMemberData.roles, + statusCol <- "created" + ] + + let insertQuery = myChannelTable.insert(setters) + let _ = try db.run(insertQuery) + } + } + + public func handleThreadMentions(_ db: Connection, _ threadData: ThreadData) throws { + let idCol = Expression("id") + let unreadMentionsCol = Expression("unread_mentions") + + if hasThread(db, threadId: threadData.id) { + let updateQuery = threadTable + .where(idCol == threadData.id) + .update(unreadMentionsCol <- threadData.unread_mentions) + let _ = try db.run(updateQuery) + } else { + let lastReplyAtCol = Expression("last_reply_at") + let lastViewedAtCol = Expression("last_viewed_at") + let viewedAtCol = Expression("viewed_at") + let lastFetchedAtCol = Expression("last_fetched_at") + let isFollowingCol = Expression("is_following") + let unreadRepliesCol = Expression("unread_replies") + let replyCountCol = Expression("reply_count") + let statusCol = Expression("status") + + let setters: [Setter] = [ + idCol <- threadData.id, + unreadMentionsCol <- threadData.unread_mentions, + lastReplyAtCol <- threadData.last_reply_at, + lastViewedAtCol <- threadData.last_viewed_at, + viewedAtCol <- 0, + lastFetchedAtCol <- 0, + isFollowingCol <- true, + unreadRepliesCol <- threadData.unread_replies, + replyCountCol <- threadData.reply_count, + statusCol <- "created" + ] + + let insertQuery = threadTable.insert(setters) + let _ = try db.run(insertQuery) + } + } + + public func resetMyChannelMentions(_ serverUrl: String, _ channelId: String) throws { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let mentionsCol = Expression("mentions_count") + let msgCol = Expression("message_count") + let isUnreadCol = Expression("is_unread") + if hasMyChannel(db, channelId: channelId) { + let updateQuery = myChannelTable + .where(idCol == channelId) + .update(mentionsCol <- 0, + msgCol <- 0, + isUnreadCol <- false + ) + let _ = try db.run(updateQuery) + } + } + } + + public func resetThreadMentions(_ serverUrl: String, _ rootId: String) throws { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let mentionsCol = Expression("unread_mentions") + let msgCol = Expression("unread_replies") + if hasThread(db, threadId: rootId) { + let updateQuery = threadTable + .where(idCol == rootId) + .update(mentionsCol <- 0, msgCol <- 0) + let _ = try db.run(updateQuery) + } + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift new file mode 100644 index 000000000..829999a01 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift @@ -0,0 +1,36 @@ +import Foundation +import SQLite + +extension Database { + internal func queryCurrentTeamId(_ serverUrl: String) -> String? { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let valueCol = Expression("value") + let query = systemTable.where(idCol == "currentTeamId") + + if let result = try? db.pluck(query) { + return try? result.get(valueCol).replacingOccurrences(of: "\"", with: "") + } + } + + return nil + } + + public func queryTeamIdForChannel(withId channelId: String, withServerUrl serverUrl: String) -> String? { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let teamIdCol = Expression("team_id") + let query = channelTable.where(idCol == channelId) + + if let result = try? db.pluck(query) { + var teamId = result[teamIdCol] + if teamId != nil || teamId!.isEmpty { + teamId = queryCurrentTeamId(serverUrl) + } + return teamId + } + } + + return nil + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift index f1f3e6ac1..4955a759d 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift @@ -153,6 +153,24 @@ public class Database: NSObject { } } + public func getAllActiveServerUrls() -> [String] { + guard let db = try? Connection(DEFAULT_DB_PATH) else {return []} + let lastActiveAt = Expression("last_active_at") + let identifier = Expression("identifier") + let url = Expression("url") + let query = serversTable.filter(lastActiveAt > 0 && identifier != "").order(lastActiveAt.desc) + do { + let rows = try db.prepare(query) + let servers: [String] = try rows.map { row in + return try row.get(url) + } + + return servers + } catch { + return [] + } + } + public func getCurrentServerDatabase() -> T? { guard let db = try? Connection(DEFAULT_DB_PATH) else {return nil} do { diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 5f0c668f5..31cb7c3ce 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 7F4288042865D340006B48E1 /* Gekidou in Frameworks */ = {isa = PBXBuildFile; productRef = 7F4288032865D340006B48E1 /* Gekidou */; }; 7F42880A286672F6006B48E1 /* ServerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F428809286672F6006B48E1 /* ServerService.swift */; }; 7F42880C2866A9C0006B48E1 /* ChannelService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F42880B2866A9C0006B48E1 /* ChannelService.swift */; }; + 7F537B2928A517580086D6B3 /* NotificationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F537B2828A517580086D6B3 /* NotificationHelper.swift */; }; 7F581D35221ED5C60099E66B /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F581D34221ED5C60099E66B /* NotificationService.swift */; }; 7F581D39221ED5C60099E66B /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7F581D32221ED5C60099E66B /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7F7E9F462864E6C60064BFAF /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7E9F442864E6C60064BFAF /* Color.swift */; }; @@ -97,6 +98,7 @@ 7FD482682864DC5900A5B18B /* OpenSans-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E5C16B14E1CE4868886A1A00 /* OpenSans-SemiBold.ttf */; }; 7FD482692864DC5900A5B18B /* OpenSans-SemiBoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0DB14DFDF6E04FA69FE769DC /* OpenSans-SemiBoldItalic.ttf */; }; 7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FEB109A1F61019C0039A015 /* MattermostManaged.m */; }; + 7FEC870128A4325D00DE96CB /* NotificationsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FEC870028A4325D00DE96CB /* NotificationsModule.m */; }; A94508A396424B2DB778AFE9 /* OpenSans-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E5C16B14E1CE4868886A1A00 /* OpenSans-SemiBold.ttf */; }; /* End PBXBuildFile section */ @@ -184,6 +186,7 @@ 7F428809286672F6006B48E1 /* ServerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerService.swift; sourceTree = ""; }; 7F42880B2866A9C0006B48E1 /* ChannelService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelService.swift; sourceTree = ""; }; 7F43D6051F6BF9EB001FC614 /* libPods-Mattermost.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libPods-Mattermost.a"; path = "../../../../../../../Library/Developer/Xcode/DerivedData/Mattermost-czlinsdviifujheezzjvmisotjrm/Build/Products/Debug-iphonesimulator/libPods-Mattermost.a"; sourceTree = ""; }; + 7F537B2828A517580086D6B3 /* NotificationHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NotificationHelper.swift; path = Mattermost/NotificationHelper.swift; sourceTree = ""; }; 7F581D32221ED5C60099E66B /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7F581D34221ED5C60099E66B /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 7F581D36221ED5C60099E66B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -237,6 +240,8 @@ 7FD482282864D69700A5B18B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7FEB10991F61019C0039A015 /* MattermostManaged.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MattermostManaged.h; path = Mattermost/MattermostManaged.h; sourceTree = ""; }; 7FEB109A1F61019C0039A015 /* MattermostManaged.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MattermostManaged.m; path = Mattermost/MattermostManaged.m; sourceTree = ""; }; + 7FEC870028A4325D00DE96CB /* NotificationsModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NotificationsModule.m; path = Mattermost/NotificationsModule.m; sourceTree = ""; }; + 7FEC870428A44A7B00DE96CB /* NotificationsModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = NotificationsModule.h; path = Mattermost/NotificationsModule.h; sourceTree = ""; }; 7FFE32B51FD9CCAA0038C7A0 /* FLAnimatedImage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FLAnimatedImage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7FFE32B61FD9CCAA0038C7A0 /* KSCrash.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = KSCrash.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7FFE32B71FD9CCAA0038C7A0 /* KSCrash.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = KSCrash.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -330,22 +335,19 @@ 13B07FAE1A68108700A75B9A /* Mattermost */ = { isa = PBXGroup; children = ( - 536CC6C223E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.h */, - 536CC6C123E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.m */, + 7F537B2128A514CB0086D6B3 /* Extensions */, + 7F537B2728A517430086D6B3 /* Helpers */, + 7F537B2428A515730086D6B3 /* Modules */, + 7F537B2328A515600086D6B3 /* Utils */, + 7F537B2228A515400086D6B3 /* Wrappers */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.mm */, - 7F151D3D221B062700FAD8F3 /* RuntimeUtils.swift */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB71A68108700A75B9A /* main.m */, 7F63D2C21E6DD98A001FAE12 /* Mattermost.entitlements */, 7F151D43221B082A00FAD8F3 /* Mattermost-Bridging-Header.h */, - 7FEB10991F61019C0039A015 /* MattermostManaged.h */, - 7FEB109A1F61019C0039A015 /* MattermostManaged.m */, 7F0F4B0924BA173900E14C60 /* LaunchScreen.storyboard */, - 7FCEFB9126B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.h */, - 7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */, - 7F1EB88427FDE361002E7EEC /* GekidouWrapper.swift */, ); name = Mattermost; sourceTree = ""; @@ -414,6 +416,52 @@ path = ViewModels; sourceTree = ""; }; + 7F537B2128A514CB0086D6B3 /* Extensions */ = { + isa = PBXGroup; + children = ( + 536CC6C223E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.h */, + 536CC6C123E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.m */, + 7FCEFB9126B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.h */, + 7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */, + ); + name = Extensions; + sourceTree = ""; + }; + 7F537B2228A515400086D6B3 /* Wrappers */ = { + isa = PBXGroup; + children = ( + 7F1EB88427FDE361002E7EEC /* GekidouWrapper.swift */, + ); + name = Wrappers; + sourceTree = ""; + }; + 7F537B2328A515600086D6B3 /* Utils */ = { + isa = PBXGroup; + children = ( + 7F151D3D221B062700FAD8F3 /* RuntimeUtils.swift */, + ); + name = Utils; + sourceTree = ""; + }; + 7F537B2428A515730086D6B3 /* Modules */ = { + isa = PBXGroup; + children = ( + 7FEB10991F61019C0039A015 /* MattermostManaged.h */, + 7FEB109A1F61019C0039A015 /* MattermostManaged.m */, + 7FEC870428A44A7B00DE96CB /* NotificationsModule.h */, + 7FEC870028A4325D00DE96CB /* NotificationsModule.m */, + ); + name = Modules; + sourceTree = ""; + }; + 7F537B2728A517430086D6B3 /* Helpers */ = { + isa = PBXGroup; + children = ( + 7F537B2828A517580086D6B3 /* NotificationHelper.swift */, + ); + name = Helpers; + sourceTree = ""; + }; 7F581D33221ED5C60099E66B /* NotificationService */ = { isa = PBXGroup; children = ( @@ -938,10 +986,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7FEC870128A4325D00DE96CB /* NotificationsModule.m in Sources */, 7F1EB88527FDE361002E7EEC /* GekidouWrapper.swift in Sources */, 7FCEFB9326B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 7F151D3E221B062700FAD8F3 /* RuntimeUtils.swift in Sources */, + 7F537B2928A517580086D6B3 /* NotificationHelper.swift in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, 7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */, 536CC6C323E79287002C478C /* RNNotificationEventHandler+HandleReplyAction.m in Sources */, diff --git a/ios/Mattermost/AppDelegate.mm b/ios/Mattermost/AppDelegate.mm index 06e8ead8b..120e9ee8d 100644 --- a/ios/Mattermost/AppDelegate.mm +++ b/ios/Mattermost/AppDelegate.mm @@ -120,9 +120,6 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; -(void)application:(UIApplication *)application didReceiveRemoteNotification:(nonnull NSDictionary *)userInfo fetchCompletionHandler:(nonnull void (^)(UIBackgroundFetchResult))completionHandler { UIApplicationState state = [UIApplication sharedApplication].applicationState; NSString* action = [userInfo objectForKey:@"type"]; - NSString* channelId = [userInfo objectForKey:@"channel_id"]; - NSString* rootId = [userInfo objectForKey:@"root_id"]; - BOOL isCRTEnabled = [userInfo objectForKey:@"is_crt_enabled"]; BOOL isClearAction = (action && [action isEqualToString: NOTIFICATION_CLEAR_ACTION]); BOOL isTestAction = (action && [action isEqualToString: NOTIFICATION_TEST_ACTION]); @@ -136,7 +133,7 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; // If received a notification that a channel was read, remove all notifications from that channel (only with app in foreground/background) // When CRT is ON: // When rootId is nil, clear channel's root post notifications or else clear all thread notifications - [self cleanNotificationsFromChannel:channelId :rootId :isCRTEnabled]; + [[NotificationHelper default] clearChannelOrThreadNotificationsWithUserInfo:userInfo]; [[GekidouWrapper default] postNotificationReceipt:userInfo]; } @@ -197,42 +194,6 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; return extraModules; } --(void)cleanNotificationsFromChannel:(NSString *)channelId :(NSString *)rootId :(BOOL)isCRTEnabled { - if ([UNUserNotificationCenter class]) { - UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; - [center getDeliveredNotificationsWithCompletionHandler:^(NSArray * _Nonnull notifications) { - NSMutableArray *notificationIds = [NSMutableArray new]; - - for (UNNotification *prevNotification in notifications) { - UNNotificationRequest *notificationRequest = [prevNotification request]; - UNNotificationContent *notificationContent = [notificationRequest content]; - NSString *identifier = [notificationRequest identifier]; - NSString* cId = [[notificationContent userInfo] objectForKey:@"channel_id"]; - NSString* pId = [[notificationContent userInfo] objectForKey:@"post_id"]; - NSString* rId = [[notificationContent userInfo] objectForKey:@"root_id"]; - - if ([cId isEqualToString: channelId]) { - BOOL doesNotificationMatch = true; - if (isCRTEnabled) { - // Check if it is a thread notification - if (rootId != nil) { - doesNotificationMatch = [pId isEqualToString: rootId] || [rId isEqualToString: rootId]; - } else { - // With CRT ON, remove notifications without rootId - doesNotificationMatch = rId == nil; - } - } - if (doesNotificationMatch) { - [notificationIds addObject:identifier]; - } - } - } - - [center removeDeliveredNotificationsWithIdentifiers:notificationIds]; - }]; - } -} - /* https://mattermost.atlassian.net/browse/MM-10601 Required by react-native-hw-keyboard-event diff --git a/ios/Mattermost/NotificationHelper.swift b/ios/Mattermost/NotificationHelper.swift new file mode 100644 index 000000000..d8b82c556 --- /dev/null +++ b/ios/Mattermost/NotificationHelper.swift @@ -0,0 +1,96 @@ +import Foundation +import Gekidou +import UserNotifications +import UIKit + +@objc class NotificationHelper: NSObject { + @objc public static let `default` = NotificationHelper() + private let notificationCenter = UNUserNotificationCenter.current() + + @objc func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { + notificationCenter.getDeliveredNotifications(completionHandler: completionHandler) + } + + @objc func clearChannelOrThreadNotifications(userInfo: NSDictionary) { + let channelId = userInfo["channel_id"] as? String + let rootId = userInfo["root_id"] as? String ?? "" + let crtEnabled = userInfo["is_crt_enabled"] as? Bool ?? false + let serverId = userInfo["server_id"] as? String ?? "" + let skipThreadNotification = !rootId.isEmpty && crtEnabled + + guard let serverUrl = try? Gekidou.Database.default.getServerUrlForServer(serverId) + else { return } + + if !skipThreadNotification && channelId != nil { + removeChannelNotifications(serverUrl: serverUrl, channelId: channelId!) + try? Gekidou.Database.default.resetMyChannelMentions(serverUrl, channelId!) + } else if !rootId.isEmpty { + removeThreadNotifications(serverUrl: serverUrl, threadId: rootId) + try? Gekidou.Database.default.resetThreadMentions(serverUrl, rootId) + } + + let mentions = Gekidou.Database.default.getTotalMentions() + UIApplication.shared.applicationIconBadgeNumber = mentions + } + + @objc func removeChannelNotifications(serverUrl: String, channelId: String) { + getDeliveredNotifications(completionHandler: {notifications in + var notificationIds = [String]() + + for notification in notifications { + let request = notification.request + let content = request.content + let identifier = request.identifier + let cId = content.userInfo["channel_id"] as? String + let rootId = content.userInfo["root_id"] as? String ?? "" + let crtEnabled = content.userInfo["is_crt_enabled"] as? Bool ?? false + let skipThreadNotification = !rootId.isEmpty && crtEnabled + + if cId == channelId && !skipThreadNotification { + notificationIds.append(identifier) + } + } + + self.notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationIds) + }) + } + + @objc func removeThreadNotifications(serverUrl: String, threadId: String) { + getDeliveredNotifications(completionHandler: {notifications in + var notificationIds = [String]() + + for notification in notifications { + let request = notification.request + let content = request.content + let identifier = request.identifier + let postId = content.userInfo["post_id"] as? String + let rootId = content.userInfo["root_id"] as? String + + if rootId == threadId || postId == threadId { + notificationIds.append(identifier) + } + } + + self.notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationIds) + }) + } + + @objc func removeServerNotifications(serverUrl: String) { + getDeliveredNotifications(completionHandler: {notifications in + var notificationIds = [String]() + + for notification in notifications { + let request = notification.request + let content = request.content + let identifier = request.identifier + let url = content.userInfo["server_url"] as? String + + if url == serverUrl { + notificationIds.append(identifier) + } + } + + self.notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationIds) + }) + } +} diff --git a/ios/Mattermost/NotificationsModule.h b/ios/Mattermost/NotificationsModule.h new file mode 100644 index 000000000..6d25b8158 --- /dev/null +++ b/ios/Mattermost/NotificationsModule.h @@ -0,0 +1,6 @@ +#import +#import "RCTConvert+RNNotifications.h" + +@interface NotificationsModule : NSObject + +@end diff --git a/ios/Mattermost/NotificationsModule.m b/ios/Mattermost/NotificationsModule.m new file mode 100644 index 000000000..7e943c0b8 --- /dev/null +++ b/ios/Mattermost/NotificationsModule.m @@ -0,0 +1,32 @@ + +#import "Mattermost-Swift.h" +#import "NotificationsModule.h" + +@implementation NotificationsModule + +RCT_EXPORT_MODULE(Notifications) + +RCT_EXPORT_METHOD(getDeliveredNotifications:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + [[NotificationHelper default] getDeliveredNotificationsWithCompletionHandler:^(NSArray * _Nonnull notifications) { + NSMutableArray *formattedNotifications = [NSMutableArray new]; + + for (UNNotification *notification in notifications) { + [formattedNotifications addObject:[RCTConvert UNNotificationPayload:notification]]; + } + resolve(formattedNotifications); + }]; +} + +RCT_EXPORT_METHOD(removeChannelNotifications:(NSString *) serverUrl channelId:(NSString*) channelId) { + [[NotificationHelper default] removeChannelNotificationsWithServerUrl:serverUrl channelId:channelId]; +} + +RCT_EXPORT_METHOD(removeThreadNotifications:(NSString *) serverUrl threadId:(NSString*) threadId) { + [[NotificationHelper default] removeThreadNotificationsWithServerUrl:serverUrl threadId:threadId]; +} + +RCT_EXPORT_METHOD(removeServerNotifications:(NSString *) serverUrl) { + [[NotificationHelper default] removeServerNotificationsWithServerUrl:serverUrl]; +} + +@end diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index aa50f3b32..436b2c2cc 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -3,6 +3,7 @@ import UserNotifications class NotificationService: UNNotificationServiceExtension { let preferences = Gekidou.Preferences.default + let fibonacciBackoffsInSeconds = [1.0, 2.0, 3.0, 5.0, 8.0] var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? @@ -11,38 +12,6 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler - let fibonacciBackoffsInSeconds = [1.0, 2.0, 3.0, 5.0, 8.0] - - func fetchReceipt(_ ackNotification: AckNotification) -> Void { - if (self.retryIndex >= fibonacciBackoffsInSeconds.count) { - contentHandler(self.bestAttemptContent!) - return - } - - Network.default.postNotificationReceipt(ackNotification) { data, response, error in - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { - contentHandler(self.bestAttemptContent!) - return - } - - guard let data = data, error == nil else { - if (ackNotification.isIdLoaded) { - // Receipt retrieval failed. Kick off retries. - let backoffInSeconds = fibonacciBackoffsInSeconds[self.retryIndex] - - DispatchQueue.main.asyncAfter(deadline: .now() + backoffInSeconds, execute: { - fetchReceipt(ackNotification) - }) - - self.retryIndex += 1 - } - return - } - - self.processResponse(serverUrl: ackNotification.serverUrl, data: data, bestAttemptContent: self.bestAttemptContent!, contentHandler: contentHandler) - } - } - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent = bestAttemptContent, let jsonData = try? JSONSerialization.data(withJSONObject: bestAttemptContent.userInfo), @@ -87,6 +56,37 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } + + func fetchReceipt(_ ackNotification: AckNotification) -> Void { + if (self.retryIndex >= self.fibonacciBackoffsInSeconds.count) { + self.contentHandler?(self.bestAttemptContent!) + return + } + + Network.default.postNotificationReceipt(ackNotification) { data, response, error in + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + self.contentHandler?(self.bestAttemptContent!) + return + } + + guard let data = data, error == nil else { + if (ackNotification.isIdLoaded) { + // Receipt retrieval failed. Kick off retries. + let backoffInSeconds = self.fibonacciBackoffsInSeconds[self.retryIndex] + + DispatchQueue.main.asyncAfter(deadline: .now() + backoffInSeconds, execute: { + self.fetchReceipt(ackNotification) + }) + + self.retryIndex += 1 + } + return + } + + self.processResponse(serverUrl: ackNotification.serverUrl, data: data, bestAttemptContent: self.bestAttemptContent!, contentHandler: self.contentHandler) + } + } + } extension Date { diff --git a/patches/react-native-notifications+4.3.1.patch b/patches/react-native-notifications+4.3.1.patch index 5bd51bcc7..8e1f9810e 100644 --- a/patches/react-native-notifications+4.3.1.patch +++ b/patches/react-native-notifications+4.3.1.patch @@ -21,10 +21,10 @@ index 24cd226..4bfacba 100644 diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java -index db9eaba..af65d0e 100644 +index 90969b2..778cb29 100644 --- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java +++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java -@@ -100,7 +100,12 @@ public class RNNotificationsModule extends ReactContextBaseJavaModule implements +@@ -102,7 +102,12 @@ public class RNNotificationsModule extends ReactContextBaseJavaModule implements if(BuildConfig.DEBUG) Log.d(LOGTAG, "Native method invocation: postLocalNotification"); final Bundle notificationProps = Arguments.toBundle(notificationPropsMap); final IPushNotification pushNotification = PushNotification.get(getReactApplicationContext().getApplicationContext(), notificationProps); @@ -38,19 +38,6 @@ index db9eaba..af65d0e 100644 } @ReactMethod -@@ -109,6 +114,12 @@ public class RNNotificationsModule extends ReactContextBaseJavaModule implements - notificationsDrawer.onNotificationClearRequest(notificationId); - } - -+ @ReactMethod -+ public void cancelAllLocalNotifications() { -+ IPushNotificationsDrawer notificationDrawer = PushNotificationsDrawer.get(getReactApplicationContext().getApplicationContext()); -+ notificationDrawer.onCancelAllLocalNotifications(); -+ } -+ - @ReactMethod - public void setCategories(ReadableArray categories) { - diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/helpers/ScheduleNotificationHelper.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/helpers/ScheduleNotificationHelper.java new file mode 100644 index 0000000..dde4a2c @@ -173,7 +160,7 @@ index 0d70024..b9e6c88 100644 PushNotificationProps asProps(); } diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java -index b93f762..400e086 100644 +index 54a5fb8..f38881e 100644 --- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java +++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java @@ -8,6 +8,10 @@ import android.content.Context; @@ -338,18 +325,8 @@ index 0000000..58ff887 + pushNotification.onPostScheduledRequest(notificationId); + } +} -diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java -index e22cd62..48aa1cd 100644 ---- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java -+++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java -@@ -11,4 +11,5 @@ public interface IPushNotificationsDrawer { - void onNotificationClearRequest(int id); - void onNotificationClearRequest(String tag, int id); - void onAllNotificationsClearRequest(); -+ void onCancelAllLocalNotifications(); - } diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java -index a14089f..1262e6d 100644 +index a14089f..a339934 100644 --- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java +++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java @@ -2,9 +2,15 @@ package com.wix.reactnativenotifications.core.notificationdrawer; @@ -368,17 +345,11 @@ index a14089f..1262e6d 100644 public class PushNotificationsDrawer implements IPushNotificationsDrawer { -@@ -62,4 +68,37 @@ public class PushNotificationsDrawer implements IPushNotificationsDrawer { +@@ -62,4 +68,31 @@ public class PushNotificationsDrawer implements IPushNotificationsDrawer { final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.cancelAll(); } + -+ @Override -+ public void onCancelAllLocalNotifications() { -+ onAllNotificationsClearRequest(); -+ cancelAllScheduledNotifications(); -+ } -+ + protected void cancelAllScheduledNotifications() { + Log.i(LOGTAG, "Cancelling all scheduled notifications"); + ScheduleNotificationHelper helper = ScheduleNotificationHelper.getInstance(mContext); @@ -428,43 +399,11 @@ index ad7fc1a..a04ec6b 100644 } get title() { return this.payload.title; -diff --git a/node_modules/react-native-notifications/lib/dist/Notifications.d.ts b/node_modules/react-native-notifications/lib/dist/Notifications.d.ts -index 6e49fd4..1d94217 100644 ---- a/node_modules/react-native-notifications/lib/dist/Notifications.d.ts -+++ b/node_modules/react-native-notifications/lib/dist/Notifications.d.ts -@@ -37,6 +37,10 @@ export declare class NotificationsRoot { - * cancelLocalNotification - */ - cancelLocalNotification(notificationId: number): void; -+ /** -+ * cancelAllLocalNotifications -+ */ -+ cancelAllLocalNotifications(): void; - /** - * removeAllDeliveredNotifications - */ -diff --git a/node_modules/react-native-notifications/lib/dist/Notifications.js b/node_modules/react-native-notifications/lib/dist/Notifications.js -index 44ab53f..8000701 100644 ---- a/node_modules/react-native-notifications/lib/dist/Notifications.js -+++ b/node_modules/react-native-notifications/lib/dist/Notifications.js -@@ -55,6 +55,12 @@ class NotificationsRoot { - cancelLocalNotification(notificationId) { - return this.commands.cancelLocalNotification(notificationId); - } -+ /** -+ * cancelAllLocalNotifications -+ */ -+ cancelAllLocalNotifications() { -+ this.commands.cancelAllLocalNotifications(); -+ } - /** - * removeAllDeliveredNotifications - */ diff --git a/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m b/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m -index 4b33656..0f189d6 100644 +index afd5c73..6036dda 100644 --- a/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m +++ b/node_modules/react-native-notifications/lib/ios/RNNotificationCenter.m -@@ -83,7 +83,7 @@ +@@ -87,7 +87,7 @@ for (UNNotification *notification in notifications) { [formattedNotifications addObject:[RCTConvert UNNotificationPayload:notification]]; } @@ -472,40 +411,4 @@ index 4b33656..0f189d6 100644 + callback(formattedNotifications); }]; } - -diff --git a/node_modules/react-native-notifications/lib/src/Notifications.ts b/node_modules/react-native-notifications/lib/src/Notifications.ts -index 0848f6d..ceb271d 100644 ---- a/node_modules/react-native-notifications/lib/src/Notifications.ts -+++ b/node_modules/react-native-notifications/lib/src/Notifications.ts -@@ -80,6 +80,13 @@ export class NotificationsRoot { - return this.commands.cancelLocalNotification(notificationId); - } - -+ /** -+ * cancelAllLocalNotifications -+ */ -+ public cancelAllLocalNotifications() { -+ this.commands.cancelAllLocalNotifications(); -+ } -+ - /** - * removeAllDeliveredNotifications - */ -diff --git a/node_modules/react-native-notifications/lib/src/NotificationsIOS.ts b/node_modules/react-native-notifications/lib/src/NotificationsIOS.ts -index 98fc19d..0c8ea3d 100644 ---- a/node_modules/react-native-notifications/lib/src/NotificationsIOS.ts -+++ b/node_modules/react-native-notifications/lib/src/NotificationsIOS.ts -@@ -52,13 +52,6 @@ export class NotificationsIOS { - return this.commands.setBadgeCount(count); - } - -- /** -- * cancelAllLocalNotifications -- */ -- public cancelAllLocalNotifications() { -- this.commands.cancelAllLocalNotifications(); -- } -- - /** - * checkPermissions - */ + \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts index c2052c80c..f19657af8 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -92,6 +92,12 @@ jest.doMock('react-native', () => { }, }), }, + Notifications: { + getDeliveredNotifications: jest.fn().mockResolvedValue([]), + removeChannelNotifications: jest.fn().mockImplementation(), + removeThreadNotifications: jest.fn().mockImplementation(), + removeServerNotifications: jest.fn().mockImplementation(), + }, APIClient: { getConstants: () => ({ EVENTS: { @@ -301,7 +307,6 @@ jest.mock('react-native-notifications', () => { setDeliveredNotifications: jest.fn((notifications) => { deliveredNotifications = notifications; }), - cancelAllLocalNotifications: jest.fn(), NotificationAction: jest.fn(), NotificationCategory: jest.fn(), events: () => ({ diff --git a/types/global/native_notifications.d.ts b/types/global/native_notifications.d.ts index 63671426b..87ecfe08e 100644 --- a/types/global/native_notifications.d.ts +++ b/types/global/native_notifications.d.ts @@ -1,24 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -interface NativeNotificationSound { - name: string; - uri: string; -} - -interface NativeNotificationPreferences { - selectedUri?: string; - shouldBlink: boolean; - shouldVibrate: boolean; - sounds: NativeNotificationSound[]; -} - interface NativeNotification { getDeliveredNotifications(): Promise; - getPreferences(): Promise; - play(soundUri: string): void; - removeDeliveredNotifications(identifier: string | string[], channelId?: string): void; - setNotificationSound(): void; - setShouldBlink(shouldBlink: boolean): void; - setShouldVibrate(shouldVibrate: boolean): void; + removeChannelNotifications(serverUrl: string, channelId: string): void; + removeThreadNotifications(serverUrl: string, threadId: string): void; + removeServerNotifications(serverUrl: string): void; }