diff --git a/android/app/src/main/java/com/mattermost/helpers/BitmapCache.kt b/android/app/src/main/java/com/mattermost/helpers/BitmapCache.kt index b576735073..41b8361ff5 100644 --- a/android/app/src/main/java/com/mattermost/helpers/BitmapCache.kt +++ b/android/app/src/main/java/com/mattermost/helpers/BitmapCache.kt @@ -5,6 +5,7 @@ import android.util.LruCache class BitmapCache { private var memoryCache: LruCache + private var keysCache: LruCache init { val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() @@ -14,15 +15,35 @@ class BitmapCache { return bitmap.byteCount / 1024 } } + keysCache = LruCache(50) } - fun getBitmapFromMemCache(key: String): Bitmap? { + fun bitmap(userId: String, updatedAt: Double, serverUrl: String): Bitmap? { + val key = "$serverUrl-$userId-$updatedAt" return memoryCache.get(key) } - fun addBitmapToMemoryCache(key: String, bitmap: Bitmap) { - if (getBitmapFromMemCache(key) == null) { - memoryCache.put(key, bitmap) + fun insertBitmap(bitmap: Bitmap?, userId: String, updatedAt: Double, serverUrl: String) { + if (bitmap == null) { + removeBitmap(userId, serverUrl) + } + val key = "$serverUrl-$userId-$updatedAt" + val cachedKey = "$serverUrl-$userId" + keysCache.put(cachedKey, key) + memoryCache.put(key, bitmap) + } + + fun removeBitmap(userId: String, serverUrl: String) { + val cachedKey = "$serverUrl-$userId" + val key = keysCache.get(cachedKey) + if (key != null) { + memoryCache.remove(key) + keysCache.remove(cachedKey) } } + + fun removeAllBitmaps() { + memoryCache.evictAll() + keysCache.evictAll() + } } diff --git a/android/app/src/main/java/com/mattermost/helpers/Credentials.java b/android/app/src/main/java/com/mattermost/helpers/Credentials.java index 9d0baef724..900434027e 100644 --- a/android/app/src/main/java/com/mattermost/helpers/Credentials.java +++ b/android/app/src/main/java/com/mattermost/helpers/Credentials.java @@ -34,7 +34,7 @@ public class Credentials { String service = map.getString("service"); assert service != null; if (service.isEmpty()) { - String[] credentials = token[0].split(",[ ]*"); + String[] credentials = token[0].split(", *"); if (credentials.length == 2) { token[0] = credentials[0]; } 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 8bf4393e59..488cd8e8e9 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -21,6 +21,7 @@ import android.os.Bundle; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.Person; @@ -28,6 +29,7 @@ import androidx.core.app.RemoteInput; import androidx.core.graphics.drawable.IconCompat; import com.mattermost.rnbeta.*; +import com.nozbe.watermelondb.Database; import java.io.IOException; import java.util.Date; @@ -37,6 +39,9 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import static com.mattermost.helpers.database_extension.GeneralKt.getDatabaseForServer; +import static com.mattermost.helpers.database_extension.UserKt.getLastPictureUpdate; + public class CustomPushNotificationHelper { public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01"; public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02"; @@ -55,7 +60,7 @@ public class CustomPushNotificationHelper { private static final BitmapCache bitmapCache = new BitmapCache(); - private static void addMessagingStyleMessages(NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) { + private static void addMessagingStyleMessages(Context context, NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) { String message = bundle.getString("message", bundle.getString("body")); String senderId = bundle.getString("sender_id"); String serverUrl = bundle.getString("server_url"); @@ -77,7 +82,7 @@ public class CustomPushNotificationHelper { if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { - Bitmap avatar = userAvatar(serverUrl, senderId, urlOverride); + Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride); if (avatar != null) { sender.setIcon(IconCompat.createWithBitmap(avatar)); } @@ -123,6 +128,7 @@ public class CustomPushNotificationHelper { notification.addExtras(userInfoBundle); } + @SuppressLint("UnspecifiedImmutableFlag") private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) { String postId = bundle.getString("post_id"); String serverUrl = bundle.getString("server_url"); @@ -179,8 +185,8 @@ public class CustomPushNotificationHelper { String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId; addNotificationExtras(notification, bundle); - setNotificationIcons(notification, bundle); - setNotificationMessagingStyle(notification, bundle); + setNotificationIcons(context, notification, bundle); + setNotificationMessagingStyle(context, notification, bundle); setNotificationGroup(notification, groupId, createSummary); setNotificationBadgeType(notification); @@ -256,7 +262,7 @@ public class CustomPushNotificationHelper { return title; } - private static NotificationCompat.MessagingStyle getMessagingStyle(Bundle bundle) { + private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) { NotificationCompat.MessagingStyle messagingStyle; final String senderId = "me"; final String serverUrl = bundle.getString("server_url"); @@ -269,7 +275,7 @@ public class CustomPushNotificationHelper { if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { - Bitmap avatar = userAvatar(serverUrl, "me", urlOverride); + Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride); if (avatar != null) { sender.setIcon(IconCompat.createWithBitmap(avatar)); } @@ -282,7 +288,7 @@ public class CustomPushNotificationHelper { String conversationTitle = getConversationTitle(bundle); setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle); - addMessagingStyleMessages(messagingStyle, conversationTitle, bundle); + addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle); return messagingStyle; } @@ -364,8 +370,8 @@ public class CustomPushNotificationHelper { notification.setDeleteIntent(deleteIntent); } - private static void setNotificationMessagingStyle(NotificationCompat.Builder notification, Bundle bundle) { - NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(bundle); + private static void setNotificationMessagingStyle(Context context, NotificationCompat.Builder notification, Bundle bundle) { + NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(context, bundle); notification.setStyle(messagingStyle); } @@ -378,7 +384,7 @@ public class CustomPushNotificationHelper { } } - private static void setNotificationIcons(NotificationCompat.Builder notification, Bundle bundle) { + private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) { String channelName = getConversationTitle(bundle); String senderName = bundle.getString("sender_name"); String serverUrl = bundle.getString("server_url"); @@ -389,7 +395,7 @@ public class CustomPushNotificationHelper { if (serverUrl != null && channelName.equals(senderName)) { try { String senderId = bundle.getString("sender_id"); - Bitmap avatar = userAvatar(serverUrl, senderId, urlOverride); + Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride); if (avatar != null) { notification.setLargeIcon(avatar); } @@ -399,19 +405,33 @@ public class CustomPushNotificationHelper { } } - private static Bitmap userAvatar(final String serverUrl, final String userId, final String urlOverride) throws IOException { + private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl, final String userId, final String urlOverride) throws IOException { try { Response response; + Double lastUpdateAt = 0.0; if (!TextUtils.isEmpty(urlOverride)) { Request request = new Request.Builder().url(urlOverride).build(); Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride)); response = client.newCall(request).execute(); } else { - Bitmap cached = bitmapCache.getBitmapFromMemCache(userId); + DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance(); + if (dbHelper != null) { + Database db = getDatabaseForServer(dbHelper, context, serverUrl); + if (db != null) { + lastUpdateAt = getLastPictureUpdate(db, userId); + if (lastUpdateAt == null) { + lastUpdateAt = 0.0; + } + db.close(); + } + } + Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl); if (cached != null) { Bitmap bitmap = cached.copy(cached.getConfig(), false); return getCircleBitmap(bitmap); } + + bitmapCache.removeBitmap(userId, serverUrl); String url = String.format("api/v4/users/%s/image", userId); Log.i("ReactNative", String.format("Fetch profile image %s", url)); response = Network.getSync(serverUrl, url, null); @@ -422,7 +442,7 @@ public class CustomPushNotificationHelper { byte[] bytes = Objects.requireNonNull(response.body()).bytes(); Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) { - bitmapCache.addBitmapToMemoryCache(userId, bitmap.copy(bitmap.getConfig(), false)); + bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl); } return getCircleBitmap(bitmap); } 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 8b78b96fad..d299415be9 100644 --- a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt +++ b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt @@ -2,32 +2,26 @@ package com.mattermost.helpers import android.content.Context import android.net.Uri -import android.text.TextUtils -import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.NoSuchKeyException -import com.facebook.react.bridge.ReadableArray -import com.facebook.react.bridge.ReadableMap + import com.nozbe.watermelondb.Database -import com.nozbe.watermelondb.mapCursor -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject + import java.lang.Exception -import java.util.* + +import org.json.JSONArray +import org.json.JSONObject class DatabaseHelper { - private var defaultDatabase: Database? = null + var defaultDatabase: Database? = null val onlyServerUrl: String? get() { try { val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''" - val cursor = defaultDatabase!!.rawQuery(query) - if (cursor.count == 1) { - cursor.moveToFirst() - val url = cursor.getString(0) - cursor.close() - return url + defaultDatabase!!.rawQuery(query).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + return cursor.getString(0) + } } } catch (e: Exception) { e.printStackTrace() @@ -42,640 +36,13 @@ class DatabaseHelper { } } - fun getServerUrlForIdentifier(identifier: String): String? { - try { - val args: Array = arrayOf(identifier) - val query = "SELECT url FROM Servers WHERE identifier=?" - val cursor = defaultDatabase!!.rawQuery(query, args) - if (cursor.count == 1) { - cursor.moveToFirst() - val url = cursor.getString(0) - cursor.close() - return url - } - } catch (e: Exception) { - e.printStackTrace() - // let it fall to return null - } - return null - } - - fun find(db: Database, tableName: String, id: String?): ReadableMap? { - val args: Array = arrayOf(id) - try { - db.rawQuery("select * from $tableName where id == ? limit 1", args).use { cursor -> - if (cursor.count <= 0) { - return null - } - val resultMap = Arguments.createMap() - cursor.moveToFirst() - resultMap.mapCursor(cursor) - return resultMap - } - } catch (e: Exception) { - e.printStackTrace() - return null - } - } - - fun getDatabaseForServer(context: Context?, serverUrl: String): Database? { - try { - val args: Array = arrayOf(serverUrl) - val query = "SELECT db_path FROM Servers WHERE url=?" - val cursor = defaultDatabase!!.rawQuery(query, args) - if (cursor.count == 1) { - cursor.moveToFirst() - val databasePath = cursor.getString(0) - cursor.close() - return Database(databasePath, context!!) - } - } catch (e: Exception) { - e.printStackTrace() - // let it fall to return null - } - return null - } - - fun queryIds(db: Database, tableName: String, ids: Array): List { - val list: MutableList = ArrayList() - 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()) { - val index = cursor.getColumnIndex("id") - if (index >= 0) { - list.add(cursor.getString(index)) - } - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } - return list - } - - fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array): List { - val list: MutableList = ArrayList() - 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()) { - val index = cursor.getColumnIndex(columnName) - if (index >= 0) { - list.add(cursor.getString(index)) - } - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } - return list - } - - fun queryCurrentUserId(db: Database): String? { - val result = find(db, "System", "currentUserId")!! - return result.getString("value") - } - - 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)) - if (cursor1.count == 1) { - cursor1.moveToFirst() - val earliest = cursor1.getDouble(0) - val latest = cursor1.getDouble(1) - cursor1.close() - val postQuery = "SELECT create_at FROM POST WHERE channel_id= ? AND delete_at=0 AND create_at BETWEEN ? AND ? ORDER BY create_at DESC" - val cursor2 = db.rawQuery(postQuery, arrayOf(channelId, earliest, latest)) - if (cursor2.count >= 60) { - cursor2.moveToFirst() - val createAt = cursor2.getDouble(0) - cursor2.close() - return createAt - } - } - } - return null - } - - fun queryPostSinceForChannel(db: Database?, channelId: String): Double? { - try { - if (db != null) { - val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1" - val cursor1 = db.rawQuery(postsInChannelQuery, arrayOf(channelId)) - if (cursor1.count == 1) { - cursor1.moveToFirst() - val lastFetchedAt = cursor1.getDouble(0) - cursor1.close() - if (lastFetchedAt == 0.0) { - return queryLastPostCreateAt(db, channelId) - } - return lastFetchedAt - } - } - } catch (e: Exception) { - e.printStackTrace() - // let it fall to return null - } - return null - } - - fun handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) { - // Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users - if (postsData != null) { - val ordered = postsData.getArray("order")?.toArrayList() - val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap() - val previousPostId = postsData.getString("prev_post_id") - val postsInThread = hashMapOf>() - val postList = posts.toList() - var earliest = 0.0 - var latest = 0.0 - var lastFetchedAt = 0.0 - - if (ordered != null && posts.isNotEmpty()) { - val firstId = ordered.first() - val lastId = ordered.last() - lastFetchedAt = postList.fold(0.0) { acc, next -> - 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 - val value = maxOf(createAt, updateAt, deleteAt) - - maxOf(value, acc) - } - var prevPostId = "" - - val sortedPosts = postList.sortedBy { (_, value) -> - ((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 - - if (index == 0) { - post.putIfAbsent("prev_post_id", previousPostId) - } else if (prevPostId.isNotEmpty()) { - post.putIfAbsent("prev_post_id", prevPostId) - } - - if (lastId == key) { - earliest = post["create_at"] as Double - } - if (firstId == key) { - latest = post["create_at"] as Double - } - - val jsonPost = JSONObject(post) - val rootId = post["root_id"] as? String - - if (!rootId.isNullOrEmpty()) { - var thread = postsInThread[rootId]?.toMutableList() - if (thread == null) { - thread = mutableListOf() - } - - thread.add(jsonPost) - postsInThread[rootId] = thread.toList() - } - - if (find(db, "Post", key) == null) { - insertPost(db, jsonPost) - } else { - updatePost(db, jsonPost) - } - - if (ordered.contains(key)) { - prevPostId = key - } - } - } - } - - if (!receivingThreads) { - handlePostsInChannel(db, channelId, earliest, latest) - updateMyChannelLastFetchedAt(db, channelId, lastFetchedAt) - } - handlePostsInThread(db, postsInThread) - } - } - - fun handleThreads(db: Database, threads: ReadableArray) { - for (i in 0 until threads.size()) { - val thread = threads.getMap(i) - val threadId = thread.getString("id") - - // Insert/Update the thread - val existingRecord = find(db, "Thread", threadId) - if (existingRecord == null) { - insertThread(db, thread) - } else { - updateThread(db, thread, existingRecord) - } - - // Delete existing and insert thread participants - val participants = thread.getArray("participants") - if (participants != null) { - db.execute("delete from ThreadParticipant where thread_id = ?", arrayOf(threadId)) - - if (participants.size() > 0) { - insertThreadParticipants(db, threadId!!, participants) - } - } - } - } - - fun handleUsers(db: Database, users: ReadableArray) { - for (i in 0 until users.size()) { - val user = users.getMap(i) - val roles = user.getString("roles") ?: "" - val isBot = try { - user.getBoolean("is_bot") - } catch (e: NoSuchKeyException) { - false - } - - val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 } - - - db.execute( - "insert into User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest, " + - "last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props, " + - "props, timezone, _status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created')", - arrayOf( - user.getString("id"), - user.getString("auth_service"), - user.getDouble("update_at"), - user.getDouble("delete_at"), - user.getString("email"), - user.getString("first_name"), - isBot, - roles.contains("system_guest"), - user.getString("last_name"), - lastPictureUpdate, - user.getString("locale"), - user.getString("nickname"), - user.getString("position"), - roles, - "", - user.getString("username"), - "{}", - ReadableMapUtils.toJSONObject(user.getMap("props") ?: Arguments.createMap()).toString(), - ReadableMapUtils.toJSONObject(user.getMap("timezone") ?: Arguments.createMap()).toString(), - ) - ) - } - } - private fun setDefaultDatabase(context: Context) { val databaseName = "app.db" val databasePath = Uri.fromFile(context.filesDir).toString() + "/" + databaseName defaultDatabase = Database(databasePath, context) } - private fun insertPost(db: Database, post: JSONObject) { - var metadata: JSONObject? - var reactions: JSONArray? = null - var customEmojis: JSONArray? = null - var files: JSONArray? = null - - try { - metadata = post.getJSONObject("metadata") - reactions = metadata.remove("reactions") as JSONArray? - customEmojis = metadata.remove("emojis") as JSONArray? - files = metadata.remove("files") as JSONArray? - } catch (e: Exception) { - // no metadata found - metadata = JSONObject() - } - - db.execute( - "insert into Post " + - "(id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, metadata, original_id, pending_post_id, " + - "previous_post_id, root_id, type, user_id, props, _status)" + - " values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created')", - arrayOf( - post.getString("id"), - post.getString("channel_id"), - post.getDouble("create_at"), - post.getDouble("delete_at"), - post.getDouble("update_at"), - post.getDouble("edit_at"), - post.getBoolean("is_pinned"), - post.getString("message"), - metadata.toString(), - post.getString("original_id"), - post.getString("pending_post_id"), - post.getString("prev_post_id"), - post.getString("root_id"), - post.getString("type"), - post.getString("user_id"), - post.getJSONObject("props").toString() - ) - ) - - if (reactions != null && reactions.length() > 0) { - insertReactions(db, reactions) - } - - if (customEmojis != null && customEmojis.length() > 0) { - insertCustomEmojis(db, customEmojis) - } - - if (files != null && files.length() > 0) { - insertFiles(db, files) - } - } - - private fun updatePost(db: Database, post: JSONObject) { - var metadata: JSONObject? - var reactions: JSONArray? = null - var customEmojis: JSONArray? = null - - try { - metadata = post.getJSONObject("metadata") - reactions = metadata.remove("reactions") as JSONArray? - customEmojis = metadata.remove("emojis") as JSONArray? - metadata.remove("files") - } catch (e: Exception) { - // no metadata found - metadata = JSONObject() - } - - db.execute( - "update Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?, " + - "is_pinned = ?, message = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?, " + - "root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated' " + - "where id = ?", - arrayOf( - post.getString("channel_id"), - post.getDouble("create_at"), - post.getDouble("delete_at"), - post.getDouble("update_at"), - post.getDouble("edit_at"), - post.getBoolean("is_pinned"), - post.getString("message"), - metadata.toString(), - post.getString("original_id"), - post.getString("pending_post_id"), - post.getString("prev_post_id"), - post.getString("root_id"), - post.getString("type"), - post.getString("user_id"), - post.getJSONObject("props").toString(), - post.getString("id"), - ) - ) - - if (reactions != null && reactions.length() > 0) { - db.execute("delete from Reaction where post_id = ?", arrayOf(post.getString("id"))) - insertReactions(db, reactions) - } - - if (customEmojis != null && customEmojis.length() > 0) { - insertCustomEmojis(db, customEmojis) - } - } - - 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 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 " + - "(id, last_reply_at, last_fetched_at, last_viewed_at, reply_count, is_following, unread_replies, unread_mentions, _status)" + - " values (?, ?, 0, ?, ?, ?, ?, ?, 'created')", - arrayOf( - thread.getString("id"), - lastReplyAt, - lastViewedAt, - replyCount, - isFollowing, - unreadReplies, - unreadMentions - ) - ) - } - - 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 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( - lastReplyAt, - lastViewedAt, - replyCount, - isFollowing, - unreadReplies, - unreadMentions, - thread.getString("id") - ) - ) - } - - private fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) { - for (i in 0 until participants.size()) { - val participant = participants.getMap(i) - val id = RandomId.generate() - db.execute( - "insert into ThreadParticipant " + - "(id, thread_id, user_id, _status)" + - " values (?, ?, ?, 'created')", - arrayOf( - id, - threadId, - participant.getString("id") - ) - ) - } - } - - private fun insertCustomEmojis(db: Database, customEmojis: JSONArray) { - for (i in 0 until customEmojis.length()) { - val emoji = customEmojis.getJSONObject(i) - if(find(db, "CustomEmoji", emoji.getString("id")) == null) { - db.execute( - "insert into CustomEmoji (id, name, _status) values (?, ?, 'created')", - arrayOf( - emoji.getString("id"), - emoji.getString("name"), - ) - ) - } - } - } - - 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 } - db.execute( - "insert into File (id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _status) " + - "values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, 'created')", - arrayOf( - file.getString("id"), - file.getString("extension"), - height, - miniPreview, - file.getString("mime_type"), - file.getString("name"), - file.getString("post_id"), - file.getDouble("size"), - width - ) - ) - } - } - - private fun insertReactions(db: Database, reactions: JSONArray) { - for (i in 0 until reactions.length()) { - val reaction = reactions.getJSONObject(i) - val id = RandomId.generate() - db.execute( - "insert into Reaction (id, create_at, emoji_name, post_id, user_id, _status) " + - "values (?, ?, ?, ?, ?, 'created')", - arrayOf( - id, - reaction.getDouble("create_at"), - reaction.getString("emoji_name"), - reaction.getString("post_id"), - reaction.getString("user_id") - ) - ) - } - } - - private fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) { - db.rawQuery("select id, channel_id, earliest, latest from PostsInChannel where channel_id = ?", arrayOf(channelId)).use { cursor -> - if (cursor.count == 0) { - // create new post in channel - insertPostInChannel(db, channelId, earliest, latest) - return - } - - val resultArray = Arguments.createArray() - while (cursor.moveToNext()) { - val cursorMap = Arguments.createMap() - cursorMap.mapCursor(cursor) - resultArray.pushMap(cursorMap) - } - - val chunk = findPostInChannel(resultArray, earliest, latest) - if (chunk != null) { - db.execute( - "update PostsInChannel set earliest = ?, latest = ?, _status = 'updated' where id = ?", - arrayOf( - minOf(earliest, chunk.getDouble("earliest")), - maxOf(latest, chunk.getDouble("latest")), - chunk.getString("id") - ) - ) - return - } - - val newChunk = insertPostInChannel(db, channelId, earliest, latest) - mergePostsInChannel(db, resultArray, newChunk) - } - } - - private fun updateMyChannelLastFetchedAt(db: Database, channelId: String, lastFetchedAt: Double) { - db.execute( - "UPDATE MyChannel SET last_fetched_at = ?, _status = 'updated' WHERE id = ?", - arrayOf( - lastFetchedAt, - channelId - ) - ) - } - - private fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? { - for (i in 0 until chunks.size()) { - val chunk = chunks.getMap(i) - if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) { - return chunk - } - } - - return null - } - - private fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap { - val id = RandomId.generate() - db.execute("insert into PostsInChannel (id, channel_id, earliest, latest, _status) values (?, ?, ?, ?, 'created')", - arrayOf(id, channelId, earliest, latest)) - - val map = Arguments.createMap() - map.putString("id", id) - map.putString("channel_id", channelId) - map.putDouble("earliest", earliest) - map.putDouble("latest", latest) - return map - } - - private fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) { - for (i in 0 until existingChunks.size()) { - val chunk = existingChunks.getMap(i) - if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") && - newChunk.getDouble("latest") >= chunk.getDouble("latest")) { - db.execute("delete from PostsInChannel where id = ?", arrayOf(chunk.getString("id"))) - break - } - } - } - - private fun handlePostsInThread(db: Database, postsInThread: Map>) { - postsInThread.forEach { (key, list) -> - val sorted = list.sortedBy { it.getDouble("create_at") } - val earliest = sorted.first().getDouble("create_at") - val latest = sorted.last().getDouble("create_at") - db.rawQuery("select * from PostsInThread where root_id = ? order by latest desc", arrayOf(key)).use { cursor -> - if (cursor.count > 0) { - cursor.moveToFirst() - val cursorMap = Arguments.createMap() - cursorMap.mapCursor(cursor) - db.execute( - "update PostsInThread set earliest = ?, latest = ?, _status = 'updated' where id = ?", - arrayOf( - minOf(earliest, cursorMap.getDouble("earliest")), - maxOf(latest, cursorMap.getDouble("latest")), - key - ) - ) - return - } - - val id = RandomId.generate() - db.execute( - "insert into PostsInThread (id, root_id, earliest, latest, _status) " + - "values (?, ?, ?, ?, 'created')", - arrayOf(id, key, earliest, latest) - ) - } - } - } - - private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { it -> + internal fun JSONObject.toMap(): Map = keys().asSequence().associateWith { it -> when (val value = this[it]) { is JSONArray -> @@ -683,9 +50,15 @@ class DatabaseHelper { val map = (0 until value.length()).associate { Pair(it.toString(), value[it]) } JSONObject(map).toMap().values.toList() } - is JSONObject -> value.toMap() - JSONObject.NULL -> null - else -> value + is JSONObject -> { + value.toMap() + } + JSONObject.NULL -> { + null + } + else -> { + value + } } } 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 1b183661b4..43e67739c5 100644 --- a/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt +++ b/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt @@ -2,293 +2,124 @@ package com.mattermost.helpers import android.content.Context import android.os.Bundle +import android.text.TextUtils import android.util.Log -import com.facebook.react.bridge.Arguments + import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.WritableNativeArray -import com.nozbe.watermelondb.Database -import java.io.IOException -import java.util.concurrent.Executors -import kotlin.coroutines.* + +import com.mattermost.helpers.database_extension.* +import com.mattermost.helpers.push_notification.* + import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class PushNotificationDataHelper(private val context: Context) { - private var scope = Executors.newSingleThreadExecutor() + private var coroutineScope = CoroutineScope(Dispatchers.Default) fun fetchAndStoreDataForPushNotification(initialData: Bundle) { - scope.execute(Runnable { - runBlocking { - PushNotificationDataRunnable.start(context, initialData) - } - }) + val job = coroutineScope.launch(Dispatchers.Default) { + PushNotificationDataRunnable.start(context, initialData) + } + runBlocking { + job.join() + } } } class PushNotificationDataRunnable { companion object { - private val specialMentions = listOf("all", "here", "channel") + internal val specialMentions = listOf("all", "here", "channel") + private val dbHelper = DatabaseHelper.instance!! + private val mutex = Mutex() - @Synchronized suspend fun start(context: Context, initialData: Bundle) { - try { + // for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/ + mutex.withLock { val serverUrl: String = initialData.getString("server_url") ?: return - val channelId = initialData.getString("channel_id") - 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) + val db = dbHelper.getDatabaseForServer(context, serverUrl) - if (db != null) { - var postData: ReadableMap? - var posts: ReadableMap? = null - var userIdsToLoad: ReadableArray? = null - var usernamesToLoad: ReadableArray? = null + try { + val teamId = initialData.getString("team_id") + val channelId = initialData.getString("channel_id") + val rootId = initialData.getString("root_id") + val isCRTEnabled = initialData.getString("is_crt_enabled") == "true" + Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId") - var threads: ReadableArray? = null - var usersFromThreads: ReadableArray? = null - val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty() + if (db != null) { + var teamData: ReadableMap? = null + var myTeamData: ReadableMap? = null + var channelData: ReadableMap? = null + var myChannelData: ReadableMap? = null + var loadedProfiles: ReadableArray? = null + var postData: ReadableMap? + var posts: ReadableMap? = null + var userIdsToLoad: ReadableArray? = null + var usernamesToLoad: ReadableArray? = null - coroutineScope { - if (channelId != null) { - postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId) + var threads: ReadableArray? = null + var usersFromThreads: ReadableArray? = null + val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty() - posts = postData?.getMap("posts") - userIdsToLoad = postData?.getArray("userIdsToLoad") - usernamesToLoad = postData?.getArray("usernamesToLoad") - threads = postData?.getArray("threads") - usersFromThreads = postData?.getArray("usersFromThreads") - - if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) { - val users = fetchUsersById(serverUrl, userIdsToLoad!!) - userIdsToLoad = users?.getArray("data") + coroutineScope { + if (teamId != null && !TextUtils.isEmpty(teamId)) { + val res = fetchTeamIfNeeded(db, serverUrl, teamId) + teamData = res.first + myTeamData = res.second } - if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) { - val users = fetchUsersByUsernames(serverUrl, usernamesToLoad!!) - usernamesToLoad = users?.getArray("data") + if (channelId != null) { + val channelRes = fetchMyChannel(db, serverUrl, channelId, isCRTEnabled) + channelData = channelRes.first + myChannelData = channelRes.second + loadedProfiles = channelRes.third + + postData = fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId, loadedProfiles) + + posts = postData?.getMap("posts") + userIdsToLoad = postData?.getArray("userIdsToLoad") + usernamesToLoad = postData?.getArray("usernamesToLoad") + threads = postData?.getArray("threads") + usersFromThreads = postData?.getArray("usersFromThreads") + + if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) { + val users = fetchUsersById(serverUrl, userIdsToLoad!!) + userIdsToLoad = users?.getArray("data") + } + + if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) { + val users = fetchUsersByUsernames(serverUrl, usernamesToLoad!!) + usernamesToLoad = users?.getArray("data") + } } } + + db.transaction { + teamData?.let { insertTeam(db, it) } + myTeamData?.let { insertMyTeam(db, it) } + channelData?.let { handleChannel(db, it) } + myChannelData?.let { handleMyChannel(db, it) } + + if (channelId != null) { + dbHelper.handlePosts(db, posts?.getMap("data"), channelId, receivingThreads) + } + + threads?.let { handleThreads(db, it) } + + loadedProfiles?.let { handleUsers(db, it) } + userIdsToLoad?.let { handleUsers(db, it) } + usernamesToLoad?.let { handleUsers(db, it) } + usersFromThreads?.let { handleUsers(db, it) } + } + + Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId") } - - db.transaction { - if (posts != null && channelId != null) { - DatabaseHelper.instance!!.handlePosts(db, posts!!.getMap("data"), channelId, receivingThreads) - } - - if (threads != null) { - DatabaseHelper.instance!!.handleThreads(db, threads!!) - } - - if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) { - DatabaseHelper.instance!!.handleUsers(db, userIdsToLoad!!) - } - - if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) { - DatabaseHelper.instance!!.handleUsers(db, usernamesToLoad!!) - } - - if (usersFromThreads != null) { - DatabaseHelper.instance!!.handleUsers(db, usersFromThreads!!) - } - } - - db.close() - Log.i("ReactNative", "Done processing push notification="+serverUrl+" for channel="+channelId) + } catch (e: Exception) { + e.printStackTrace() + } finally { + db?.close() + Log.i("ReactNative", "DONE fetching notification data") } - } catch (e: Exception) { - e.printStackTrace() - } - } - - private suspend fun fetchPosts(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): ReadableMap? { - val regex = Regex("""\B@(([a-z0-9-._]*[a-z0-9_])[.-]*)""", setOf(RegexOption.IGNORE_CASE)) - val since = DatabaseHelper.instance!!.queryPostSinceForChannel(db, channelId) - val currentUserId = DatabaseHelper.instance!!.queryCurrentUserId(db)?.removeSurrounding("\"") - val currentUser = DatabaseHelper.instance!!.find(db, "User", currentUserId) - val currentUsername = currentUser?.getString("username") - - var additionalParams = "" - if (isCRTEnabled) { - additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true" - } - - val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty() - val endpoint = if (receivingThreads) { - val queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up" - "/api/v4/posts/$rootId/thread$queryParams$additionalParams" - } else { - 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) - val results = Arguments.createMap() - - if (postsResponse != null) { - val data = ReadableMapUtils.toMap(postsResponse) - results.putMap("posts", postsResponse) - val postsData = data["data"] as? Map<*, *> - if (postsData != null) { - val postsMap = postsData["posts"] - if (postsMap != null) { - val posts = ReadableMapUtils.toWritableMap(postsMap as? Map) - val iterator = posts.keySetIterator() - val userIds = mutableListOf() - val usernames = mutableListOf() - - val threads = WritableNativeArray() - val threadParticipantUserIds = mutableListOf() // Used to exclude the "userIds" present in the thread participants - val threadParticipantUsernames = mutableListOf() // Used to exclude the "usernames" present in the thread participants - val threadParticipantUsers = HashMap() // All unique users from thread participants are stored here - - while(iterator.hasNextKey()) { - val key = iterator.nextKey() - val post = posts.getMap(key) - val userId = post?.getString("user_id") - if (userId != null && userId != currentUserId && !userIds.contains(userId)) { - userIds.add(userId) - } - val message = post?.getString("message") - if (message != null) { - val matchResults = regex.findAll(message) - matchResults.iterator().forEach { - val username = it.value.removePrefix("@") - if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) { - usernames.add(username) - } - } - } - - if (isCRTEnabled) { - // Add root post as a thread - val threadId = post?.getString("root_id") - if (threadId.isNullOrEmpty()) { - threads.pushMap(post!!) - } - - // Add participant userIds and usernames to exclude them from getting fetched again - val participants = post.getArray("participants") - if (participants != null) { - for (i in 0 until participants.size()) { - val participant = participants.getMap(i) - - val participantId = participant.getString("id") - if (participantId != currentUserId && participantId != null) { - if (!threadParticipantUserIds.contains(participantId)) { - threadParticipantUserIds.add(participantId) - } - - if (!threadParticipantUsers.containsKey(participantId)) { - threadParticipantUsers[participantId] = participant - } - } - - val username = participant.getString("username") - if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) { - threadParticipantUsernames.add(username) - } - } - } - } - } - - val existingUserIds = DatabaseHelper.instance!!.queryIds(db, "User", userIds.toTypedArray()) - val existingUsernames = DatabaseHelper.instance!!.queryByColumn(db, "User", "username", usernames.toTypedArray()) - userIds.removeAll { it in existingUserIds } - usernames.removeAll { it in existingUsernames } - - if (threadParticipantUserIds.size > 0) { - // Do not fetch users found in thread participants as we get the user's data in the posts response already - userIds.removeAll { it in threadParticipantUserIds } - usernames.removeAll { it in threadParticipantUsernames } - - // Get users from thread participants - val existingThreadParticipantUserIds = DatabaseHelper.instance!!.queryIds(db, "User", threadParticipantUserIds.toTypedArray()) - - // Exclude the thread participants already present in the DB from getting inserted again - val usersFromThreads = WritableNativeArray() - threadParticipantUsers.forEach{ (userId, user) -> - if (!existingThreadParticipantUserIds.contains(userId)) { - usersFromThreads.pushMap(user) - } - } - - if (usersFromThreads.size() > 0) { - results.putArray("usersFromThreads", usersFromThreads) - } - } - - if (userIds.size > 0) { - results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray())) - } - - if (usernames.size > 0) { - results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray())) - } - - if (threads.size() > 0) { - results.putArray("threads", threads) - } - } - } - } - return results - } - - private suspend fun fetchUsersById(serverUrl: String, userIds: ReadableArray): ReadableMap? { - val endpoint = "api/v4/users/ids" - val options = Arguments.createMap() - options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds))) - 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) - } - - private suspend fun fetch(serverUrl: String, endpoint: String): ReadableMap? { - return suspendCoroutine { cont -> - Network.get(serverUrl, endpoint, null, object : ResolvePromise() { - override fun resolve(value: Any?) { - val response = value as ReadableMap? - if (response != null && !response.getBoolean("ok")) { - val error = response.getMap("data") - cont.resumeWith(Result.failure((IOException("Unexpected code ${error?.getInt("status_code")} ${error?.getString("message")}")))) - } else { - cont.resumeWith(Result.success(response)) - } - } - - override fun reject(code: String, message: String) { - cont.resumeWith(Result.failure(IOException("Unexpected code $code $message"))) - } - - override fun reject(reason: Throwable?) { - cont.resumeWith(Result.failure(IOException("Unexpected code $reason"))) - } - }) - } - } - - private suspend fun fetchWithPost(serverUrl: String, endpoint: String, options: ReadableMap?) : ReadableMap? { - return suspendCoroutine { cont -> - Network.post(serverUrl, endpoint, options, object : ResolvePromise() { - override fun resolve(value: Any?) { - val response = value as ReadableMap? - cont.resumeWith(Result.success(response)) - } - - override fun reject(code: String, message: String) { - cont.resumeWith(Result.failure(IOException("Unexpected code $code $message"))) - } - - override fun reject(reason: Throwable?) { - cont.resumeWith(Result.failure(IOException("Unexpected code $reason"))) - } - }) } } } 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 6c51801721..1508b40c13 100644 --- a/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java +++ b/android/app/src/main/java/com/mattermost/helpers/RealPathUtil.java @@ -122,24 +122,37 @@ public class RealPathUtil { File cacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME); - if (!cacheDir.exists()) { - cacheDir.mkdirs(); + boolean cacheDirExists = cacheDir.exists(); + if (!cacheDirExists) { + cacheDirExists = cacheDir.mkdirs(); } - tmpFile = new File(cacheDir, fileName); - tmpFile.createNewFile(); + if (cacheDirExists) { + tmpFile = new File(cacheDir, fileName); + boolean fileCreated = tmpFile.createNewFile(); - ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r"); + if (fileCreated) { + ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r"); - FileChannel src = new FileInputStream(pfd.getFileDescriptor()).getChannel(); - FileChannel dst = new FileOutputStream(tmpFile).getChannel(); - dst.transferFrom(src, 0, src.size()); - src.close(); - dst.close(); + try (FileInputStream inputSrc = new FileInputStream(pfd.getFileDescriptor())) { + FileChannel src = inputSrc.getChannel(); + try (FileOutputStream outputDst = new FileOutputStream(tmpFile)) { + FileChannel dst = outputDst.getChannel(); + dst.transferFrom(src, 0, src.size()); + src.close(); + dst.close(); + } + } + + pfd.close(); + } + return tmpFile.getAbsolutePath(); + } } catch (IOException ex) { - return null; + ex.printStackTrace(); } - return tmpFile.getAbsolutePath(); + + return null; } public static String getDataColumn(Context context, Uri uri, String selection, @@ -245,7 +258,9 @@ public class RealPathUtil { } } - fileOrDirectory.delete(); + if (!fileOrDirectory.delete()) { + Log.i("ReactNative", "Couldn't delete file " + fileOrDirectory.getName()); + } } private static String sanitizeFilename(String filename) { @@ -256,22 +271,4 @@ public class RealPathUtil { File f = new File(filename); return f.getName(); } - - public static File createDirIfNotExists(String path) { - File dir = new File(path); - if (dir.exists()) { - return dir; - } - - try { - dir.mkdirs(); - // Add .nomedia to hide the thumbnail directory from gallery - File noMedia = new File(path, ".nomedia"); - noMedia.createNewFile(); - } catch (IOException e) { - e.printStackTrace(); - } - - return dir; - } } diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt new file mode 100644 index 0000000000..dd41120f5a --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Channel.kt @@ -0,0 +1,213 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.ReadableMapUtils +import com.nozbe.watermelondb.Database +import org.json.JSONException +import org.json.JSONObject + +fun findChannel(db: Database?, channelId: String): Boolean { + if (db != null) { + val team = find(db, "Channel", channelId) + return team != null + } + return false +} + +fun findMyChannel(db: Database?, channelId: String): Boolean { + if (db != null) { + val team = find(db, "MyChannel", channelId) + return team != null + } + return false +} + +internal fun updateMyChannelLastFetchedAt(db: Database, channelId: String, lastFetchedAt: Double) { + try { + db.execute( + "UPDATE MyChannel SET last_fetched_at = ?, _status = 'updated' WHERE id = ?", + arrayOf( + lastFetchedAt, + channelId + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } +} + +internal fun handleChannel(db: Database, channel: ReadableMap) { + try { + val exists = channel.getString("id")?.let { findChannel(db, it) } ?: false + if (!exists) { + val json = ReadableMapUtils.toJSONObject(channel) + if (insertChannel(db, json)) { + insertChannelInfo(db, json) + } + } + } catch (e: Exception) { + e.printStackTrace() + } +} + +internal fun handleMyChannel(db: Database, myChannel: ReadableMap) { + try { + val json = ReadableMapUtils.toJSONObject(myChannel) + val exists = myChannel.getString("id")?.let { findMyChannel(db, it) } ?: false + if (exists) { + updateMyChannel(db, json) + return + } + + if (insertMyChannel(db, json)) { + insertMyChannelSettings(db, json) + insertChannelMember(db, json) + } + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun insertChannel(db: Database, channel: JSONObject): Boolean { + val id = try { channel.getString("id") } catch (e: JSONException) { return false } + val createAt = try { channel.getDouble("create_at") } catch (e: JSONException) { 0 } + val deleteAt = try { channel.getDouble("delete_at") } catch (e: JSONException) { 0 } + val updateAt = try { channel.getDouble("update_at") } catch (e: JSONException) { 0 } + val creatorId = try { channel.getString("creator_id") } catch (e: JSONException) { "" } + val displayName = try { channel.getString("display_name") } catch (e: JSONException) { "" } + val name = try { channel.getString("name") } catch (e: JSONException) { "" } + val teamId = try { channel.getString("team_id") } catch (e: JSONException) { "" } + val type = try { channel.getString("type") } catch (e: JSONException) { "O" } + val isGroupConstrained = try { channel.getBoolean("group_constrained") } catch (e: JSONException) { false } + val shared = try { channel.getBoolean("shared") } catch (e: JSONException) { false } + + return try { + db.execute( + """ + INSERT INTO Channel + (id, create_at, delete_at, update_at, creator_id, display_name, name, team_id, type, is_group_constrained, shared, _status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created') + """.trimIndent(), + arrayOf( + id, createAt, deleteAt, updateAt, + creatorId, displayName, name, teamId, type, + isGroupConstrained, shared + ) + ) + true + } catch (e: Exception) { + e.printStackTrace() + false + } +} + +fun insertChannelInfo(db: Database, channel: JSONObject) { + val id = try { channel.getString("id") } catch (e: JSONException) { return } + val header = try { channel.getString("header") } catch (e: JSONException) { "" } + val purpose = try { channel.getString("purpose") } catch (e: JSONException) { "" } + + try { + db.execute( + """ + INSERT INTO ChannelInfo + (id, header, purpose, guest_count, member_count, pinned_post_count, _status) + VALUES (?, ?, ?, 0, 0, 0, 'created') + """.trimIndent(), + arrayOf(id, header, purpose) + ) + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun insertMyChannel(db: Database, myChanel: JSONObject): Boolean { + return try { + val id = try { myChanel.getString("id") } catch (e: JSONException) { return false } + val roles = try { myChanel.getString("roles") } catch (e: JSONException) { "" } + val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 } + val mentionsCount = try { myChanel.getInt("mentions_count") } catch (e: JSONException) { 0 } + val isUnread = try { myChanel.getBoolean("is_unread") } catch (e: JSONException) { false } + val lastPostAt = try { myChanel.getDouble("last_post_at") } catch (e: JSONException) { 0 } + val lastViewedAt = try { myChanel.getDouble("last_viewed_at") } catch (e: JSONException) { 0 } + val viewedAt = 0 + val lastFetchedAt = 0 + val manuallyUnread = false + + db.execute( + """ + INSERT INTO MyChannel + (id, roles, message_count, mentions_count, is_unread, manually_unread, + last_post_at, last_viewed_at, viewed_at, last_fetched_at, _status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created') + """, + arrayOf( + id, roles, msgCount, mentionsCount, isUnread, manuallyUnread, + lastPostAt, lastViewedAt, viewedAt, lastFetchedAt + ) + ) + + true + } catch (e: Exception) { + e.printStackTrace() + false + } +} + +fun insertMyChannelSettings(db: Database, myChanel: JSONObject) { + try { + val id = try { myChanel.getString("id") } catch (e: JSONException) { return } + val notifyProps = try { myChanel.getString("notify_props") } catch (e: JSONException) { return } + + db.execute( + """ + INSERT INTO MyChannelSettings (id, notify_props) + VALUES (?, ?) + """, + arrayOf(id, notifyProps) + ) + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun insertChannelMember(db: Database, myChanel: JSONObject) { + try { + val userId = queryCurrentUserId(db)?.removeSurrounding("\"") ?: return + val channelId = try { myChanel.getString("id") } catch (e: JSONException) { return } + val schemeAdmin = try { myChanel.getBoolean("scheme_admin") } catch (e: JSONException) { false } + val id = "$channelId-$userId" + db.execute( + """ + INSERT INTO ChannelMembership + (id, channel_id, user_id, scheme_admin, _status) + VALUES (?, ?, ?, ?, 'created') + """, + arrayOf(id, channelId, userId, schemeAdmin) + ) + + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun updateMyChannel(db: Database, myChanel: JSONObject) { + try { + val id = try { myChanel.getString("id") } catch (e: JSONException) { return } + val msgCount = try { myChanel.getInt("message_count") } catch (e: JSONException) { 0 } + val mentionsCount = try { myChanel.getInt("mentions_count") } catch (e: JSONException) { 0 } + val isUnread = try { myChanel.getBoolean("is_unread") } catch (e: JSONException) { false } + val lastPostAt = try { myChanel.getDouble("last_post_at") } catch (e: JSONException) { 0 } + val lastViewedAt = try { myChanel.getDouble("last_viewed_at") } catch (e: JSONException) { 0 } + + db.execute( + """ + UPDATE MyChannel SET message_count=?, mentions_count=?, is_unread=?, + last_post_at=?, last_viewed_at=?, _status = 'updated' + WHERE id=? + """, + arrayOf(msgCount, mentionsCount, isUnread, lastPostAt, lastViewedAt, id) + ) + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt new file mode 100644 index 0000000000..9d08f7c498 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/CustomEmoji.kt @@ -0,0 +1,23 @@ +package com.mattermost.helpers.database_extension + +import com.nozbe.watermelondb.Database +import org.json.JSONArray + +internal fun insertCustomEmojis(db: Database, customEmojis: JSONArray) { + for (i in 0 until customEmojis.length()) { + try { + val emoji = customEmojis.getJSONObject(i) + if (find(db, "CustomEmoji", emoji.getString("id")) == null) { + db.execute( + "INSERT INTO CustomEmoji (id, name, _status) VALUES (?, ?, 'created')", + arrayOf( + emoji.getString("id"), + emoji.getString("name"), + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt new file mode 100644 index 0000000000..7158f69b32 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/File.kt @@ -0,0 +1,35 @@ +package com.mattermost.helpers.database_extension + +import com.nozbe.watermelondb.Database +import org.json.JSONArray +import org.json.JSONException + +internal fun insertFiles(db: Database, files: JSONArray) { + try { + for (i in 0 until files.length()) { + val file = files.getJSONObject(i) + val id = file.getString("id") + val extension = file.getString("extension") + val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" } + val height = try { file.getInt("height") } catch (e: JSONException) { 0 } + val mime = file.getString("mime_type") + val name = file.getString("name") + val postId = file.getString("post_id") + val size = try { file.getDouble("size") } 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') + """.trimIndent(), + arrayOf( + id, extension, height, miniPreview, + mime, name, postId, size, width + ) + ) + } + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt new file mode 100644 index 0000000000..1461339f03 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt @@ -0,0 +1,105 @@ +package com.mattermost.helpers.database_extension + +import android.content.Context +import android.text.TextUtils +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.DatabaseHelper +import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.mapCursor +import java.lang.Exception +import java.util.* + +fun DatabaseHelper.getServerUrlForIdentifier(identifier: String): String? { + try { + val query = "SELECT url FROM Servers WHERE identifier=?" + defaultDatabase!!.rawQuery(query, arrayOf(identifier)).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + return cursor.getString(0) + } + } + } catch (e: Exception) { + e.printStackTrace() + // let it fall to return null + } + return null +} + +fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): Database? { + try { + val query = "SELECT db_path FROM Servers WHERE url=?" + defaultDatabase!!.rawQuery(query, arrayOf(serverUrl)).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + val databasePath = cursor.getString(0) + return Database(databasePath, context!!) + } + } + } catch (e: Exception) { + e.printStackTrace() + // let it fall to return null + } + return null +} + +fun find(db: Database, tableName: String, id: String?): ReadableMap? { + try { + db.rawQuery( + "SELECT * FROM $tableName WHERE id == ? LIMIT 1", + arrayOf(id) + ).use { cursor -> + if (cursor.count <= 0) { + return null + } + val resultMap = Arguments.createMap() + cursor.moveToFirst() + resultMap.mapCursor(cursor) + return resultMap + } + } catch (e: Exception) { + e.printStackTrace() + return null + } +} + +fun queryIds(db: Database, tableName: String, ids: Array): List { + val list: MutableList = ArrayList() + val args = TextUtils.join(",", Arrays.stream(ids).map { "?" }.toArray()) + try { + @Suppress("UNCHECKED_CAST") + db.rawQuery("SELECT DISTINCT id FROM $tableName WHERE id IN ($args)", ids as Array).use { cursor -> + if (cursor.count > 0) { + while (cursor.moveToNext()) { + val index = cursor.getColumnIndex("id") + if (index >= 0) { + list.add(cursor.getString(index)) + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return list +} + +fun queryByColumn(db: Database, tableName: String, columnName: String, values: Array): List { + val list: MutableList = ArrayList() + 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()) { + val index = cursor.getColumnIndex(columnName) + if (index >= 0) { + list.add(cursor.getString(index)) + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return list +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt new file mode 100644 index 0000000000..6ff58d8439 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Post.kt @@ -0,0 +1,250 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.DatabaseHelper +import com.mattermost.helpers.ReadableMapUtils +import com.nozbe.watermelondb.Database +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import kotlin.Exception + +internal fun queryLastPostCreateAt(db: Database?, channelId: String): Double? { + try { + if (db != null) { + val postsInChannelQuery = "SELECT earliest, latest FROM PostsInChannel WHERE channel_id=? ORDER BY latest DESC LIMIT 1" + db.rawQuery(postsInChannelQuery, arrayOf(channelId)).use { cursor1 -> + if (cursor1.count == 1) { + cursor1.moveToFirst() + val earliest = cursor1.getDouble(0) + val latest = cursor1.getDouble(1) + val postQuery = "SELECT create_at FROM POST WHERE channel_id= ? AND delete_at=0 AND create_at BETWEEN ? AND ? ORDER BY create_at DESC" + + db.rawQuery(postQuery, arrayOf(channelId, earliest, latest)).use { cursor2 -> + if (cursor2.count >= 60) { + cursor2.moveToFirst() + return cursor2.getDouble(0) + } + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return null +} + +fun queryPostSinceForChannel(db: Database?, channelId: String): Double? { + try { + if (db != null) { + val postsInChannelQuery = "SELECT last_fetched_at FROM MyChannel WHERE id=? LIMIT 1" + db.rawQuery(postsInChannelQuery, arrayOf(channelId)).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + val lastFetchedAt = cursor.getDouble(0) + if (lastFetchedAt == 0.0) { + return queryLastPostCreateAt(db, channelId) + } + return lastFetchedAt + } + } + } + } catch (e: Exception) { + e.printStackTrace() + // let it fall to return null + } + return null +} + +internal fun insertPost(db: Database, post: JSONObject) { + try { + val id = try { post.getString("id") } catch (e: JSONException) { return } + val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return } + val userId = try { post.getString("user_id") } catch (e: JSONException) { return } + val createAt = try { post.getDouble("create_at") } catch (e: JSONException) { return } + val deleteAt = try { post.getDouble("delete_at") } catch (e: JSONException) { 0 } + val updateAt = try { post.getDouble("update_at") } catch (e: JSONException) { 0 } + val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 } + val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false } + val message = try { post.getString("message") } catch (e: JSONException) { "" } + val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() } + val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" } + val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" } + val prevId = try { post.getString("prev_post_id") } catch (e: JSONException) { "" } + val rootId = try { post.getString("root_id") } catch (e: JSONException) { "" } + val type = try { post.getString("type") } catch (e: JSONException) { "" } + val props = try { post.getJSONObject("props").toString() } catch (e: JSONException) { "" } + val reactions = metadata.remove("reactions") as JSONArray? + val customEmojis = metadata.remove("emojis") as JSONArray? + val files = metadata.remove("files") as JSONArray? + + db.execute( + """ + INSERT INTO Post + (id, channel_id, create_at, delete_at, update_at, edit_at, is_pinned, message, metadata, original_id, pending_post_id, + previous_post_id, root_id, type, user_id, props, _status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created') + """.trimIndent(), + arrayOf( + id, channelId, createAt, deleteAt, updateAt, editAt, + isPinned, message, metadata.toString(), + originalId, pendingId, prevId, rootId, + type, userId, props + ) + ) + + if (reactions != null && reactions.length() > 0) { + insertReactions(db, reactions) + } + + if (customEmojis != null && customEmojis.length() > 0) { + insertCustomEmojis(db, customEmojis) + } + + if (files != null && files.length() > 0) { + insertFiles(db, files) + } + } catch (e: Exception) { + e.printStackTrace() + } +} + +internal fun updatePost(db: Database, post: JSONObject) { + try { + val id = try { post.getString("id") } catch (e: JSONException) { return } + val channelId = try { post.getString("channel_id") } catch (e: JSONException) { return } + val userId = try { post.getString("user_id") } catch (e: JSONException) { return } + val createAt = try { post.getDouble("create_at") } catch (e: JSONException) { return } + val deleteAt = try { post.getDouble("delete_at") } catch (e: JSONException) { 0 } + val updateAt = try { post.getDouble("update_at") } catch (e: JSONException) { 0 } + val editAt = try { post.getDouble("edit_at") } catch (e: JSONException) { 0 } + val isPinned = try { post.getBoolean("is_pinned") } catch (e: JSONException) { false } + val message = try { post.getString("message") } catch (e: JSONException) { "" } + val metadata = try { post.getJSONObject("metadata") } catch (e: JSONException) { JSONObject() } + val originalId = try { post.getString("original_id") } catch (e: JSONException) { "" } + val pendingId = try { post.getString("pending_post_id") } catch (e: JSONException) { "" } + val prevId = try { post.getString("prev_post_id") } catch (e: JSONException) { "" } + val rootId = try { post.getString("root_id") } catch (e: JSONException) { "" } + val type = try { post.getString("type") } catch (e: JSONException) { "" } + val props = try { post.getJSONObject("props").toString() } catch (e: JSONException) { "" } + val reactions = metadata.remove("reactions") as JSONArray? + val customEmojis = metadata.remove("emojis") as JSONArray? + + metadata.remove("files") + + db.execute( + """ + UPDATE Post SET channel_id = ?, create_at = ?, delete_at = ?, update_at =?, edit_at =?, + is_pinned = ?, message = ?, metadata = ?, original_id = ?, pending_post_id = ?, previous_post_id = ?, + root_id = ?, type = ?, user_id = ?, props = ?, _status = 'updated' + WHERE id = ? + """.trimIndent(), + arrayOf( + channelId, createAt, deleteAt, updateAt, editAt, + isPinned, message, metadata.toString(), + originalId, pendingId, prevId, rootId, + type, userId, props, + id, + ) + ) + + if (reactions != null && reactions.length() > 0) { + db.execute("DELETE FROM Reaction WHERE post_id = ?", arrayOf(post.getString("id"))) + insertReactions(db, reactions) + } + + if (customEmojis != null && customEmojis.length() > 0) { + insertCustomEmojis(db, customEmojis) + } + } catch (e: Exception) { + e.printStackTrace() + } +} + +fun DatabaseHelper.handlePosts(db: Database, postsData: ReadableMap?, channelId: String, receivingThreads: Boolean) { + // Posts, PostInChannel, PostInThread, Reactions, Files, CustomEmojis, Users + try { + if (postsData != null) { + val ordered = postsData.getArray("order")?.toArrayList() + val posts = ReadableMapUtils.toJSONObject(postsData.getMap("posts")).toMap() + val previousPostId = postsData.getString("prev_post_id") + val postsInThread = hashMapOf>() + val postList = posts.toList() + var earliest = 0.0 + var latest = 0.0 + var lastFetchedAt = 0.0 + + if (ordered != null && posts.isNotEmpty()) { + val firstId = ordered.first() + val lastId = ordered.last() + lastFetchedAt = postList.fold(0.0) { acc, next -> + 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 + val value = maxOf(createAt, updateAt, deleteAt) + + maxOf(value, acc) + } + var prevPostId = "" + + val sortedPosts = postList.sortedBy { (_, value) -> + ((value as Map<*, *>)["create_at"] as Double) + } + + sortedPosts.forEachIndexed { index, it -> + val key = it.first + if (it.second != null) { + @Suppress("UNCHECKED_CAST", "UNCHECKED_CAST") + val post: MutableMap = it.second as MutableMap + + if (index == 0) { + post.putIfAbsent("prev_post_id", previousPostId) + } else if (prevPostId.isNotEmpty()) { + post.putIfAbsent("prev_post_id", prevPostId) + } + + if (lastId == key) { + earliest = post["create_at"] as Double + } + if (firstId == key) { + latest = post["create_at"] as Double + } + + val jsonPost = JSONObject(post) + val rootId = post["root_id"] as? String + + if (!rootId.isNullOrEmpty()) { + var thread = postsInThread[rootId]?.toMutableList() + if (thread == null) { + thread = mutableListOf() + } + + thread.add(jsonPost) + postsInThread[rootId] = thread.toList() + } + + if (find(db, "Post", key) == null) { + insertPost(db, jsonPost) + } else { + updatePost(db, jsonPost) + } + + if (ordered.contains(key)) { + prevPostId = key + } + } + } + } + + if (!receivingThreads) { + handlePostsInChannel(db, channelId, earliest, latest) + updateMyChannelLastFetchedAt(db, channelId, lastFetchedAt) + } + handlePostsInThread(db, postsInThread) + } + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt new file mode 100644 index 0000000000..46a685a0c7 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/PostsInChannel.kt @@ -0,0 +1,97 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.RandomId +import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.mapCursor + +internal fun findPostInChannel(chunks: ReadableArray, earliest: Double, latest: Double): ReadableMap? { + for (i in 0 until chunks.size()) { + val chunk = chunks.getMap(i) + if (earliest >= chunk.getDouble("earliest") || latest <= chunk.getDouble("latest")) { + return chunk + } + } + + return null +} + +internal fun insertPostInChannel(db: Database, channelId: String, earliest: Double, latest: Double): ReadableMap? { + return try { + val id = RandomId.generate() + db.execute( + """ + INSERT INTO PostsInChannel + (id, channel_id, earliest, latest, _status) + VALUES (?, ?, ?, ?, 'created') + """.trimIndent(), + arrayOf(id, channelId, earliest, latest)) + + val map = Arguments.createMap() + map.putString("id", id) + map.putString("channel_id", channelId) + map.putDouble("earliest", earliest) + map.putDouble("latest", latest) + map + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +internal fun mergePostsInChannel(db: Database, existingChunks: ReadableArray, newChunk: ReadableMap) { + for (i in 0 until existingChunks.size()) { + try { + val chunk = existingChunks.getMap(i) + if (newChunk.getDouble("earliest") <= chunk.getDouble("earliest") && + newChunk.getDouble("latest") >= chunk.getDouble("latest")) { + db.execute("DELETE FROM PostsInChannel WHERE id = ?", arrayOf(chunk.getString("id"))) + break + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +internal fun handlePostsInChannel(db: Database, channelId: String, earliest: Double, latest: Double) { + try { + db.rawQuery( + "SELECT id, channel_id, earliest, latest FROM PostsInChannel WHERE channel_id = ?", + arrayOf(channelId) + ).use { cursor -> + if (cursor.count == 0) { + // create new post in channel + insertPostInChannel(db, channelId, earliest, latest) + return + } + + val resultArray = Arguments.createArray() + while (cursor.moveToNext()) { + val cursorMap = Arguments.createMap() + cursorMap.mapCursor(cursor) + resultArray.pushMap(cursorMap) + } + + val chunk = findPostInChannel(resultArray, earliest, latest) + if (chunk != null) { + db.execute( + "UPDATE PostsInChannel SET earliest = ?, latest = ?, _status = 'updated' WHERE id = ?", + arrayOf( + minOf(earliest, chunk.getDouble("earliest")), + maxOf(latest, chunk.getDouble("latest")), + chunk.getString("id") + ) + ) + return + } + + val newChunk = insertPostInChannel(db, channelId, earliest, latest) + newChunk?.let { mergePostsInChannel(db, resultArray, it) } + } + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt new file mode 100644 index 0000000000..d41dc8f493 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Preference.kt @@ -0,0 +1,29 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.Arguments +import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.mapCursor + +fun getTeammateDisplayNameSetting(db: Database): String { + val configSetting = queryConfigDisplayNameSetting(db) + if (configSetting != null) { + return configSetting + } + + try { + db.rawQuery( + "SELECT value FROM Preference where category = ? AND name = ? limit 1", + arrayOf("display_settings", "name_format") + ).use { cursor -> + if (cursor.count <= 0) { + return "username" + } + val resultMap = Arguments.createMap() + cursor.moveToFirst() + resultMap.mapCursor(cursor) + return resultMap?.getString("value") ?: "username" + } + } catch (e: Exception) { + return "username" + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt new file mode 100644 index 0000000000..38bd693e8a --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Reaction.kt @@ -0,0 +1,28 @@ +package com.mattermost.helpers.database_extension + +import com.mattermost.helpers.RandomId +import com.nozbe.watermelondb.Database +import org.json.JSONArray + +internal fun insertReactions(db: Database, reactions: JSONArray) { + for (i in 0 until reactions.length()) { + try { + val reaction = reactions.getJSONObject(i) + val id = RandomId.generate() + db.execute( + """ + INSERT INTO Reaction + (id, create_at, emoji_name, post_id, user_id, _status) + VALUES (?, ?, ?, ?, ?, 'created') + """.trimIndent(), + arrayOf( + id, + reaction.getDouble("create_at"), reaction.getString("emoji_name"), + reaction.getString("post_id"), reaction.getString("user_id") + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt new file mode 100644 index 0000000000..e8413af282 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt @@ -0,0 +1,27 @@ +package com.mattermost.helpers.database_extension + +import com.nozbe.watermelondb.Database +import org.json.JSONObject + +fun queryCurrentUserId(db: Database): String? { + val result = find(db, "System", "currentUserId")!! + return result.getString("value") +} + +fun queryConfigDisplayNameSetting(db: Database): String? { + val license = find(db, "System", "license") + val lockDisplayName = find(db, "Config", "LockTeammateNameDisplay") + val displayName = find(db, "Config", "TeammateNameDisplay") + + val licenseValue = license?.getString("value") ?: "" + val lockDisplayNameValue = lockDisplayName?.getString("value") ?: "false" + val displayNameValue = displayName?.getString("value") ?: "full_name" + val licenseJson = JSONObject(licenseValue) + val licenseLock = try { licenseJson.getString("LockTeammateNameDisplay") } catch (e: Exception) { "false"} + + if (licenseLock == "true" && lockDisplayNameValue == "true") { + return displayNameValue + } + + return null +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt new file mode 100644 index 0000000000..2d9d77c190 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Team.kt @@ -0,0 +1,88 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.NoSuchKeyException +import com.facebook.react.bridge.ReadableMap +import com.nozbe.watermelondb.Database + +fun findTeam(db: Database?, teamId: String): Boolean { + if (db != null) { + val team = find(db, "Team", teamId) + return team != null + } + return false +} + +fun findMyTeam(db: Database?, teamId: String): Boolean { + if (db != null) { + val team = find(db, "MyTeam", teamId) + return team != null + } + return false +} + +fun insertTeam(db: Database, team: ReadableMap): Boolean { + val id = try { team.getString("id") } catch (e: Exception) { return false } + val deleteAt = try {team.getDouble("delete_at") } catch (e: Exception) { 0 } + if (deleteAt.toInt() > 0) { + return false + } + + val isAllowOpenInvite = try { team.getBoolean("allow_open_invite") } catch (e: NoSuchKeyException) { false } + val description = try { team.getString("description") } catch (e: NoSuchKeyException) { "" } + val displayName = try { team.getString("display_name") } catch (e: NoSuchKeyException) { "" } + val name = try { team.getString("name") } catch (e: NoSuchKeyException) { "" } + val updateAt = try { team.getDouble("update_at") } catch (e: NoSuchKeyException) { 0 } + val type = try { team.getString("type") } catch (e: NoSuchKeyException) { "O" } + val allowedDomains = try { team.getString("allowed_domains") } catch (e: NoSuchKeyException) { "" } + val isGroupConstrained = try { team.getBoolean("group_constrained") } catch (e: NoSuchKeyException) { false } + val lastTeamIconUpdatedAt = try { team.getDouble("last_team_icon_update") } catch (e: NoSuchKeyException) { 0 } + val inviteId = try { team.getString("invite_id") } catch (e: NoSuchKeyException) { "" } + val status = "created" + + return try { + db.execute( + """ + INSERT INTO Team ( + id, allow_open_invite, description, display_name, name, update_at, type, allowed_domains, + group_constrained, last_team_icon_update, invite_id, _status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf( + id, isAllowOpenInvite, description, displayName, name, updateAt, + type, allowedDomains, isGroupConstrained, lastTeamIconUpdatedAt, inviteId, status + ) + ) + + true + } catch (e: Exception) { + e.printStackTrace() + false + } +} + +fun insertMyTeam(db: Database, myTeam: ReadableMap): Boolean { + val currentUserId = queryCurrentUserId(db) ?: return false + val id = try { myTeam.getString("id") } catch (e: NoSuchKeyException) { return false } + val roles = try { myTeam.getString("roles") } catch (e: NoSuchKeyException) { "" } + val schemeAdmin = try { myTeam.getBoolean("scheme_admin") } catch (e: NoSuchKeyException) { false } + val status = "created" + val membershipId = "$id-$currentUserId" + + return try { + db.execute( + "INSERT INTO MyTeam (id, roles, status) VALUES (?, ?, ?)", + arrayOf(id, roles, status) + ) + db.execute( + """ + INSERT INTO TeamMembership (id, team_id, user_id, scheme_admin, status) + VALUES (?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf(membershipId, id, currentUserId, schemeAdmin, status) + ) + true + } catch (e: Exception) { + e.printStackTrace() + false + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt new file mode 100644 index 0000000000..0bf1b3c038 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/Thread.kt @@ -0,0 +1,150 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.NoSuchKeyException +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.RandomId +import com.nozbe.watermelondb.Database +import com.nozbe.watermelondb.mapCursor +import org.json.JSONObject + +internal fun insertThread(db: Database, thread: ReadableMap) { + // These fields are not present when we extract threads from posts + try { + val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return } + 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 + (id, last_reply_at, last_fetched_at, last_viewed_at, reply_count, is_following, unread_replies, unread_mentions, viewed_at, _changed, _status) + VALUES (?, ?, 0, ?, ?, ?, ?, ?, 0, '', 'created') + """.trimIndent(), + arrayOf( + id, lastReplyAt, lastViewedAt, + replyCount, isFollowing, unreadReplies, unreadMentions + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } +} + +internal fun updateThread(db: Database, thread: ReadableMap, existingRecord: ReadableMap) { + try { + // These fields are not present when we extract threads from posts + val id = try { thread.getString("id") } catch (e: NoSuchKeyException) { return } + 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 = ? + """.trimIndent(), + arrayOf( + lastReplyAt, lastViewedAt, replyCount, + isFollowing, unreadReplies, unreadMentions, id + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } +} + +internal fun insertThreadParticipants(db: Database, threadId: String, participants: ReadableArray) { + for (i in 0 until participants.size()) { + try { + val participant = participants.getMap(i) + val id = RandomId.generate() + db.execute( + """ + INSERT INTO ThreadParticipant + (id, thread_id, user_id, _status) + VALUES (?, ?, ?, 'created') + """.trimIndent(), + arrayOf(id, threadId, participant.getString("id")) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +internal fun handlePostsInThread(db: Database, postsInThread: Map>) { + postsInThread.forEach { (key, list) -> + try { + val sorted = list.sortedBy { it.getDouble("create_at") } + val earliest = sorted.first().getDouble("create_at") + val latest = sorted.last().getDouble("create_at") + db.rawQuery("SELECT * FROM PostsInThread WHERE root_id = ? ORDER BY latest DESC", arrayOf(key)).use { cursor -> + if (cursor.count > 0) { + cursor.moveToFirst() + val cursorMap = Arguments.createMap() + cursorMap.mapCursor(cursor) + db.execute( + "UPDATE PostsInThread SET earliest = ?, latest = ?, _status = 'updated' WHERE id = ?", + arrayOf( + minOf(earliest, cursorMap.getDouble("earliest")), + maxOf(latest, cursorMap.getDouble("latest")), + key + ) + ) + return + } + + val id = RandomId.generate() + db.execute( + """ + INSERT INTO PostsInThread + (id, root_id, earliest, latest, _status) + VALUES (?, ?, ?, ?, 'created') + """.trimIndent(), + arrayOf(id, key, earliest, latest) + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +fun handleThreads(db: Database, threads: ReadableArray) { + for (i in 0 until threads.size()) { + try { + val thread = threads.getMap(i) + val threadId = thread.getString("id") + + // Insert/Update the thread + val existingRecord = find(db, "Thread", threadId) + if (existingRecord == null) { + insertThread(db, thread) + } else { + updateThread(db, thread, existingRecord) + } + + // Delete existing and insert thread participants + val participants = thread.getArray("participants") + if (participants != null) { + db.execute("DELETE FROM ThreadParticipant WHERE thread_id = ?", arrayOf(threadId)) + + if (participants.size() > 0) { + insertThreadParticipants(db, threadId!!, participants) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt new file mode 100644 index 0000000000..391040fd00 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/User.kt @@ -0,0 +1,85 @@ +package com.mattermost.helpers.database_extension + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.NoSuchKeyException +import com.facebook.react.bridge.ReadableArray +import com.mattermost.helpers.ReadableMapUtils +import com.nozbe.watermelondb.Database + +fun getLastPictureUpdate(db: Database?, userId: String): Double? { + try { + if (db != null) { + var id = userId + if (userId == "me") { + (queryCurrentUserId(db)?.removeSurrounding("\"") ?: userId).also { id = it } + } + val userQuery = "SELECT last_picture_update FROM User WHERE id=?" + db.rawQuery(userQuery, arrayOf(id)).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + return cursor.getDouble(0) + } + } + + } + } catch (e: Exception) { + e.printStackTrace() + } + return null +} + +fun getCurrentUserLocale(db: Database): String { + try { + val currentUserId = queryCurrentUserId(db)?.removeSurrounding("\"") ?: return "en" + val userQuery = "SELECT locale FROM User WHERE id=?" + db.rawQuery(userQuery, arrayOf(currentUserId)).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + return cursor.getString(0) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return "en" +} + +fun handleUsers(db: Database, users: ReadableArray) { + for (i in 0 until users.size()) { + val user = users.getMap(i) + val roles = user.getString("roles") ?: "" + val isBot = try { + user.getBoolean("is_bot") + } catch (e: NoSuchKeyException) { + false + } + + val lastPictureUpdate = try { user.getDouble("last_picture_update") } catch (e: NoSuchKeyException) { 0 } + + try { + db.execute( + """ + INSERT INTO User (id, auth_service, update_at, delete_at, email, first_name, is_bot, is_guest, + last_name, last_picture_update, locale, nickname, position, roles, status, username, notify_props, + props, timezone, _status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'created') + """.trimIndent(), + arrayOf( + user.getString("id"), + user.getString("auth_service"), user.getDouble("update_at"), user.getDouble("delete_at"), + user.getString("email"), user.getString("first_name"), isBot, + roles.contains("system_guest"), user.getString("last_name"), lastPictureUpdate, + user.getString("locale"), user.getString("nickname"), user.getString("position"), + roles, "", user.getString("username"), "{}", + ReadableMapUtils.toJSONObject(user.getMap("props") + ?: Arguments.createMap()).toString(), + ReadableMapUtils.toJSONObject(user.getMap("timezone") + ?: Arguments.createMap()).toString(), + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt new file mode 100644 index 0000000000..22f0f46d95 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Channel.kt @@ -0,0 +1,146 @@ +package com.mattermost.helpers.push_notification + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.PushNotificationDataRunnable +import com.mattermost.helpers.database_extension.findChannel +import com.mattermost.helpers.database_extension.getCurrentUserLocale +import com.mattermost.helpers.database_extension.getTeammateDisplayNameSetting +import com.mattermost.helpers.database_extension.queryCurrentUserId +import com.nozbe.watermelondb.Database +import java.text.Collator +import java.util.Locale + +suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple { + val channel = fetch(serverUrl, "/api/v4/channels/$channelId") + var channelData = channel?.getMap("data") + val myChannelData = channelData?.let { fetchMyChannelData(serverUrl, channelId, isCRTEnabled, it) } + val channelType = channelData?.getString("type") + var profilesArray: ReadableArray? = null + + if (channelData != null && channelType != null && !findChannel(db, channelId)) { + val displayNameSetting = getTeammateDisplayNameSetting(db) + + when (channelType) { + "D" -> { + profilesArray = fetchProfileInChannel(db, serverUrl, channelId) + if ((profilesArray?.size() ?: 0) > 0) { + val displayName = displayUsername(profilesArray!!.getMap(0), displayNameSetting) + val data = Arguments.createMap() + data.merge(channelData) + data.putString("display_name", displayName) + channelData = data + } + } + "G" -> { + profilesArray = fetchProfileInChannel(db, serverUrl, channelId) + if ((profilesArray?.size() ?: 0) > 0) { + val localeString = getCurrentUserLocale(db) + val localeArray = localeString.split("-") + val locale = if (localeArray.size == 1) { + Locale(localeString) + } else { + Locale(localeArray[0], localeArray[1]) + } + val displayName = displayGroupMessageName(profilesArray!!, locale, displayNameSetting) + val data = Arguments.createMap() + data.merge(channelData) + data.putString("display_name", displayName) + channelData = data + } + } + else -> {} + } + } + + return Triple(channelData, myChannelData, profilesArray) +} + +private suspend fun PushNotificationDataRunnable.Companion.fetchMyChannelData(serverUrl: String, channelId: String, isCRTEnabled: Boolean, channelData: ReadableMap): ReadableMap? { + val myChannel = fetch(serverUrl, "/api/v4/channels/$channelId/members/me") + val myChannelData = myChannel?.getMap("data") + if (myChannelData != null) { + val data = Arguments.createMap() + data.merge(myChannelData) + data.putString("id", channelId) + val totalMsg = if (isCRTEnabled) { + channelData.getInt("total_msg_count_root") + } else { + channelData.getInt("total_msg_count") + } + val myMsgCount = if (isCRTEnabled) { + myChannelData.getInt("msg_count_root") + } else { + myChannelData.getInt("msg_count") + } + + val mentionCount = if (isCRTEnabled) { + myChannelData.getInt("mention_count_root") + } else { + myChannelData.getInt("mention_count") + } + + val lastPostAt = if (isCRTEnabled) { + try { channelData.getDouble("last_root_post_at") } + catch (e: Exception) { channelData.getDouble("last_post_at") } + } else { + channelData.getDouble("last_post_at") + } + + data.putInt("message_count", 0.coerceAtLeast(totalMsg - myMsgCount)) + data.putInt("mentions_count", mentionCount) + data.putBoolean("is_unread", myMsgCount > 0) + data.putDouble("last_post_at", lastPostAt) + return data + } + + return null +} + +private suspend fun PushNotificationDataRunnable.Companion.fetchProfileInChannel(db: Database, serverUrl: String, channelId: String): ReadableArray? { + val currentUserId = queryCurrentUserId(db)?.removeSurrounding("\"") + val profilesInChannel = fetch(serverUrl, "/api/v4/users?in_channel=${channelId}&page=0&per_page=8&sort=") + val profilesArray = profilesInChannel?.getArray("data") + val result = Arguments.createArray() + if (profilesArray != null) { + for (i in 0 until profilesArray.size()) { + val profile = profilesArray.getMap(i) + if (profile.getString("id") != currentUserId) { + result.pushMap(profile) + } + } + } + + return result +} + +private fun PushNotificationDataRunnable.Companion.displayUsername(user: ReadableMap, displayNameSetting: String): String { + val name = user.getString("username") ?: "" + val nickname = user.getString("nickname") + val firstName = user.getString("first_name") ?: "" + val lastName = user.getString("last_name") ?: "" + return when (displayNameSetting) { + "nickname_full_name" -> { + (nickname ?: "$firstName $lastName").trim() + } + "full_name" -> { + "$firstName $lastName".trim() + } + else -> { + name.trim() + } + } +} + +private fun PushNotificationDataRunnable.Companion.displayGroupMessageName(profilesArray: ReadableArray, locale: Locale, displayNameSetting: String): String { + val names = ArrayList() + for (i in 0 until profilesArray.size()) { + val profile = profilesArray.getMap(i) + names.add(displayUsername(profile, displayNameSetting)) + } + + return names.sortedWith { s1, s2 -> + Collator.getInstance(locale).compare(s1, s2) + }.joinToString(", ").trim() +} diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/General.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/General.kt new file mode 100644 index 0000000000..1d60643be2 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/General.kt @@ -0,0 +1,53 @@ +package com.mattermost.helpers.push_notification + +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.Network +import com.mattermost.helpers.PushNotificationDataRunnable +import com.mattermost.helpers.ResolvePromise + +import java.io.IOException + +import kotlin.coroutines.suspendCoroutine + +internal suspend fun PushNotificationDataRunnable.Companion.fetch(serverUrl: String, endpoint: String): ReadableMap? { + return suspendCoroutine { cont -> + Network.get(serverUrl, endpoint, null, object : ResolvePromise() { + override fun resolve(value: Any?) { + val response = value as ReadableMap? + if (response != null && !response.getBoolean("ok")) { + val error = response.getMap("data") + cont.resumeWith(Result.failure((IOException("Unexpected code ${error?.getInt("status_code")} ${error?.getString("message")}")))) + } else { + cont.resumeWith(Result.success(response)) + } + } + + override fun reject(code: String, message: String) { + cont.resumeWith(Result.failure(IOException("Unexpected code $code $message"))) + } + + override fun reject(reason: Throwable?) { + cont.resumeWith(Result.failure(IOException("Unexpected code $reason"))) + } + }) + } +} + +internal suspend fun PushNotificationDataRunnable.Companion.fetchWithPost(serverUrl: String, endpoint: String, options: ReadableMap?) : ReadableMap? { + return suspendCoroutine { cont -> + Network.post(serverUrl, endpoint, options, object : ResolvePromise() { + override fun resolve(value: Any?) { + val response = value as ReadableMap? + cont.resumeWith(Result.success(response)) + } + + override fun reject(code: String, message: String) { + cont.resumeWith(Result.failure(IOException("Unexpected code $code $message"))) + } + + override fun reject(reason: Throwable?) { + cont.resumeWith(Result.failure(IOException("Unexpected code $reason"))) + } + }) + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt new file mode 100644 index 0000000000..6b6bb449c3 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Post.kt @@ -0,0 +1,153 @@ +package com.mattermost.helpers.push_notification + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableNativeArray +import com.mattermost.helpers.PushNotificationDataRunnable +import com.mattermost.helpers.ReadableArrayUtils +import com.mattermost.helpers.ReadableMapUtils +import com.mattermost.helpers.database_extension.* +import com.nozbe.watermelondb.Database + +internal suspend fun PushNotificationDataRunnable.Companion.fetchPosts(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?, loadedProfiles: ReadableArray?): ReadableMap? { + val regex = Regex("""\B@(([a-z\d-._]*[a-z\d_])[.-]*)""", setOf(RegexOption.IGNORE_CASE)) + val since = queryPostSinceForChannel(db, channelId) + val currentUserId = queryCurrentUserId(db)?.removeSurrounding("\"") + val currentUser = find(db, "User", currentUserId) + val currentUsername = currentUser?.getString("username") + + var additionalParams = "" + if (isCRTEnabled) { + additionalParams = "&collapsedThreads=true&collapsedThreadsExtended=true" + } + + val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty() + val endpoint = if (receivingThreads) { + val queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up" + "/api/v4/posts/$rootId/thread$queryParams$additionalParams" + } else { + 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) + val results = Arguments.createMap() + + if (postsResponse != null) { + val data = ReadableMapUtils.toMap(postsResponse) + results.putMap("posts", postsResponse) + val postsData = data["data"] as? Map<*, *> + if (postsData != null) { + val postsMap = postsData["posts"] + if (postsMap != null) { + @Suppress("UNCHECKED_CAST") + val posts = ReadableMapUtils.toWritableMap(postsMap as? Map) + val iterator = posts.keySetIterator() + val userIds = mutableListOf() + val usernames = mutableListOf() + + val threads = WritableNativeArray() + val threadParticipantUserIds = mutableListOf() // Used to exclude the "userIds" present in the thread participants + val threadParticipantUsernames = mutableListOf() // Used to exclude the "usernames" present in the thread participants + val threadParticipantUsers = HashMap() // All unique users from thread participants are stored here + val userIdsAlreadyLoaded = mutableListOf() + if (loadedProfiles != null) { + for( i in 0 until loadedProfiles.size()) { + loadedProfiles.getMap(i).getString("id")?.let { userIdsAlreadyLoaded.add(it) } + } + } + + while(iterator.hasNextKey()) { + val key = iterator.nextKey() + val post = posts.getMap(key) + val userId = post?.getString("user_id") + if (userId != null && userId != currentUserId && !userIdsAlreadyLoaded.contains(userId) && !userIds.contains(userId)) { + userIds.add(userId) + } + val message = post?.getString("message") + if (message != null) { + val matchResults = regex.findAll(message) + matchResults.iterator().forEach { + val username = it.value.removePrefix("@") + if (!usernames.contains(username) && currentUsername != username && !specialMentions.contains(username)) { + usernames.add(username) + } + } + } + + if (isCRTEnabled) { + // Add root post as a thread + val threadId = post?.getString("root_id") + if (threadId.isNullOrEmpty()) { + threads.pushMap(post!!) + } + + // Add participant userIds and usernames to exclude them from getting fetched again + val participants = post.getArray("participants") + if (participants != null) { + for (i in 0 until participants.size()) { + val participant = participants.getMap(i) + + val participantId = participant.getString("id") + if (participantId != currentUserId && participantId != null) { + if (!threadParticipantUserIds.contains(participantId) && !userIdsAlreadyLoaded.contains(participantId)) { + threadParticipantUserIds.add(participantId) + } + + if (!threadParticipantUsers.containsKey(participantId)) { + threadParticipantUsers[participantId] = participant + } + } + + val username = participant.getString("username") + if (username != null && username != currentUsername && !threadParticipantUsernames.contains(username)) { + threadParticipantUsernames.add(username) + } + } + } + } + } + + val existingUserIds = queryIds(db, "User", userIds.toTypedArray()) + val existingUsernames = queryByColumn(db, "User", "username", usernames.toTypedArray()) + userIds.removeAll { it in existingUserIds } + usernames.removeAll { it in existingUsernames } + + if (threadParticipantUserIds.size > 0) { + // Do not fetch users found in thread participants as we get the user's data in the posts response already + userIds.removeAll { it in threadParticipantUserIds } + usernames.removeAll { it in threadParticipantUsernames } + + // Get users from thread participants + val existingThreadParticipantUserIds = queryIds(db, "User", threadParticipantUserIds.toTypedArray()) + + // Exclude the thread participants already present in the DB from getting inserted again + val usersFromThreads = WritableNativeArray() + threadParticipantUsers.forEach{ (userId, user) -> + if (!existingThreadParticipantUserIds.contains(userId)) { + usersFromThreads.pushMap(user) + } + } + + if (usersFromThreads.size() > 0) { + results.putArray("usersFromThreads", usersFromThreads) + } + } + + if (userIds.size > 0) { + results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray())) + } + + if (usernames.size > 0) { + results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray())) + } + + if (threads.size() > 0) { + results.putArray("threads", threads) + } + } + } + } + return results +} diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt new file mode 100644 index 0000000000..d0b7560bc9 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/Team.kt @@ -0,0 +1,23 @@ +package com.mattermost.helpers.push_notification + +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.PushNotificationDataRunnable +import com.mattermost.helpers.database_extension.findMyTeam +import com.mattermost.helpers.database_extension.findTeam +import com.nozbe.watermelondb.Database + +suspend fun PushNotificationDataRunnable.Companion.fetchTeamIfNeeded(db: Database, serverUrl: String, teamId: String): Pair { + var team: ReadableMap? = null + var myTeam: ReadableMap? = null + val teamExists = findTeam(db, teamId) + val myTeamExists = findMyTeam(db, teamId) + if (!teamExists) { + team = fetch(serverUrl, "/api/v4/teams/$teamId") + } + + if (!myTeamExists) { + myTeam = fetch(serverUrl, "/api/v4/teams/$teamId/members/me") + } + + return Pair(team, myTeam) +} diff --git a/android/app/src/main/java/com/mattermost/helpers/push_notification/User.kt b/android/app/src/main/java/com/mattermost/helpers/push_notification/User.kt new file mode 100644 index 0000000000..628c6f6012 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/push_notification/User.kt @@ -0,0 +1,21 @@ +package com.mattermost.helpers.push_notification + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.mattermost.helpers.PushNotificationDataRunnable +import com.mattermost.helpers.ReadableArrayUtils + +internal suspend fun PushNotificationDataRunnable.Companion.fetchUsersById(serverUrl: String, userIds: ReadableArray): ReadableMap? { + val endpoint = "api/v4/users/ids" + val options = Arguments.createMap() + options.putArray("body", ReadableArrayUtils.toWritableArray(ReadableArrayUtils.toArray(userIds))) + return fetchWithPost(serverUrl, endpoint, options) +} + +internal suspend fun PushNotificationDataRunnable.Companion.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) +} 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 181e621645..e2eaa9b38d 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -7,6 +7,7 @@ import android.os.Bundle; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; import java.util.Objects; @@ -22,8 +23,11 @@ import com.wix.reactnativenotifications.core.notification.PushNotification; import com.wix.reactnativenotifications.core.AppLaunchHelper; import com.wix.reactnativenotifications.core.AppLifecycleFacade; import com.wix.reactnativenotifications.core.JsIOHelper; + +import static com.mattermost.helpers.database_extension.GeneralKt.*; import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME; + public class CustomPushNotification extends PushNotification { private final PushNotificationDataHelper dataHelper; @@ -51,7 +55,6 @@ public class CustomPushNotification extends PushNotification { int notificationId = NotificationHelper.getNotificationId(initialData); String serverUrl = addServerUrlToBundle(initialData); - boolean isReactInit = mAppLifecycleFacade.isReactInitialized(); if (ackId != null && serverUrl != null) { Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded); @@ -65,7 +68,7 @@ public class CustomPushNotification extends PushNotification { } } - finishProcessingNotification(serverUrl, type, channelId, notificationId, isReactInit); + finishProcessingNotification(serverUrl, type, channelId, notificationId); } @Override @@ -78,7 +81,9 @@ public class CustomPushNotification extends PushNotification { } } - private void finishProcessingNotification(String serverUrl, String type, String channelId, int notificationId, Boolean isReactInit) { + private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) { + final boolean isReactInit = mAppLifecycleFacade.isReactInitialized(); + switch (type) { case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE: case CustomPushNotificationHelper.PUSH_TYPE_SESSION: @@ -145,17 +150,20 @@ public class CustomPushNotification extends PushNotification { } private String addServerUrlToBundle(Bundle bundle) { + DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance(); String serverId = bundle.getString("server_id"); - String serverUrl; - if (serverId == null) { - serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getOnlyServerUrl(); - } else { - serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getServerUrlForIdentifier(serverId); - } + String serverUrl = null; + if (dbHelper != null) { + if (serverId == null) { + serverUrl = dbHelper.getOnlyServerUrl(); + } else { + serverUrl = getServerUrlForIdentifier(dbHelper, serverId); + } - if (!TextUtils.isEmpty(serverUrl)) { - bundle.putString("server_url", serverUrl); - mNotificationProps = createProps(bundle); + if (!TextUtils.isEmpty(serverUrl)) { + bundle.putString("server_url", serverUrl); + mNotificationProps = createProps(bundle); + } } return serverUrl; diff --git a/android/app/src/main/java/com/mattermost/rnbeta/FoldableObserver.kt b/android/app/src/main/java/com/mattermost/rnbeta/FoldableObserver.kt index dee8c46d59..d3019702ff 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/FoldableObserver.kt +++ b/android/app/src/main/java/com/mattermost/rnbeta/FoldableObserver.kt @@ -13,12 +13,12 @@ class FoldableObserver(private val activity: Activity) { private var disposable: Disposable? = null private lateinit var observable: Observable - public fun onCreate() { + fun onCreate() { observable = WindowInfoTracker.getOrCreate(activity) .windowLayoutInfoObservable(activity) } - public fun onStart() { + fun onStart() { if (disposable?.isDisposed == true) { onCreate() } @@ -42,7 +42,7 @@ class FoldableObserver(private val activity: Activity) { } } - public fun onStop() { + fun onStop() { disposable?.dispose() } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java index 87fa0a8d3e..0ec58ae640 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java @@ -1,6 +1,8 @@ package com.mattermost.rnbeta; import android.os.Bundle; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import android.view.KeyEvent; @@ -12,9 +14,11 @@ import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule; import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; import com.facebook.react.defaults.DefaultReactActivityDelegate; +import java.util.Objects; + public class MainActivity extends NavigationActivity { private boolean HWKeyboardConnected = false; - private FoldableObserver foldableObserver = new FoldableObserver(this); + private final FoldableObserver foldableObserver = new FoldableObserver(this); @Override protected String getMainComponentName() { @@ -30,7 +34,7 @@ public class MainActivity extends NavigationActivity { protected ReactActivityDelegate createReactActivityDelegate() { return new DefaultReactActivityDelegate( this, - getMainComponentName(), + Objects.requireNonNull(getMainComponentName()), // If you opted-in for the New Architecture, we enable the Fabric Renderer. DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18). @@ -59,7 +63,7 @@ public class MainActivity extends NavigationActivity { } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) { @@ -97,7 +101,7 @@ public class MainActivity extends NavigationActivity { } } return super.dispatchKeyEvent(event); - }; + } private void setHWKeyboardConnected() { HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY; diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java b/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java index 8b793c3dba..765fc2fe88 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MattermostManagedModule.java @@ -32,10 +32,10 @@ import com.mattermost.helpers.RealPathUtil; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; -import java.io.IOException; import java.lang.ref.WeakReference; import java.net.URL; import java.nio.channels.FileChannel; +import java.util.Objects; public class MattermostManagedModule extends ReactContextBaseJavaModule { private static final String SAVE_EVENT = "MattermostManagedSaveFile"; @@ -46,8 +46,6 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule { private Promise mPickerPromise; private String fileContent; - private static final String TAG = MattermostManagedModule.class.getSimpleName(); - private MattermostManagedModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; @@ -149,7 +147,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule { } try { final String packageName = currentActivity.getPackageName(); - final String authority = new StringBuilder(packageName).append(".provider").toString(); + final String authority = packageName + ".provider"; contentUri = FileProvider.getUriForFile(currentActivity, authority, newFile); } catch(IllegalArgumentException e) { @@ -176,7 +174,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule { intent.setType(mimeType); intent.putExtra(Intent.EXTRA_TITLE, filename); - PackageManager pm = getCurrentActivity().getPackageManager(); + PackageManager pm = Objects.requireNonNull(getCurrentActivity()).getPackageManager(); if (intent.resolveActivity(pm) != null) { try { getCurrentActivity().startActivityForResult(intent, SAVE_REQUEST); @@ -211,7 +209,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule { if (!TextUtils.isEmpty(token)) { WritableMap headers = Arguments.createMap(); if (optionsMap.hasKey("headers")) { - headers.merge(optionsMap.getMap("headers")); + headers.merge(Objects.requireNonNull(optionsMap.getMap("headers"))); } headers.putString("Authorization", "Bearer " + token); optionsMap.putMap("headers", headers); @@ -237,34 +235,21 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule { @Override protected Object doInBackgroundGuarded() { - FileChannel source = null; - FileChannel dest = null; try { - File input = new File(this.fromFile); - FileInputStream fileInputStream = new FileInputStream(input); ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w"); - FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor()); - source = fileInputStream.getChannel(); - dest = fileOutputStream.getChannel(); - dest.transferFrom(source, 0, source.size()); + File input = new File(this.fromFile); + try (FileInputStream fileInputStream = new FileInputStream(input)) { + try (FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor())) { + FileChannel source = fileInputStream.getChannel(); + FileChannel dest = fileOutputStream.getChannel(); + dest.transferFrom(source, 0, source.size()); + source.close(); + dest.close(); + } + } + pfd.close(); } catch (Exception e) { e.printStackTrace(); - } finally { - if (source != null) { - try { - source.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - if (dest != null) { - try { - dest.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } } return null; diff --git a/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java b/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java index 65c8cd2eb1..6de0faccd3 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/NotificationsModule.java @@ -1,7 +1,6 @@ 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; diff --git a/android/app/src/main/java/com/mattermost/rnbeta/SplitViewModule.kt b/android/app/src/main/java/com/mattermost/rnbeta/SplitViewModule.kt index a42a61792a..eb53e60cad 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/SplitViewModule.kt +++ b/android/app/src/main/java/com/mattermost/rnbeta/SplitViewModule.kt @@ -29,11 +29,10 @@ class SplitViewModule(private var reactContext: ReactApplicationContext) : React override fun getName() = "SplitView" - fun sendEvent(eventName: String, - params: WritableMap?) { + private fun sendEvent(params: WritableMap?) { reactContext .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(eventName, params) + .emit("SplitViewChanged", params) } private fun getSplitViewResults(folded: Boolean) : WritableMap? { @@ -51,7 +50,7 @@ class SplitViewModule(private var reactContext: ReactApplicationContext) : React fun setDeviceFolded(folded: Boolean) { val map = getSplitViewResults(folded) if (listenerCount > 0 && isDeviceFolded != folded) { - sendEvent("SplitViewChanged", map) + sendEvent(map) } isDeviceFolded = folded } diff --git a/android/app/src/main/java/com/mattermost/share/ShareModule.java b/android/app/src/main/java/com/mattermost/share/ShareModule.java index c6060cb8df..fc9bad1037 100644 --- a/android/app/src/main/java/com/mattermost/share/ShareModule.java +++ b/android/app/src/main/java/com/mattermost/share/ShareModule.java @@ -29,6 +29,7 @@ import org.json.JSONException; import java.io.IOException; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -75,8 +76,8 @@ public class ShareModule extends ReactContextBaseJavaModule { public String getCurrentActivityName() { Activity currentActivity = getCurrentActivity(); if (currentActivity != null) { - String actvName = currentActivity.getComponentName().getClassName(); - String[] components = actvName.split("\\."); + String activityName = currentActivity.getComponentName().getClassName(); + String[] components = activityName.split("\\."); return components[components.length - 1]; } @@ -115,7 +116,7 @@ public class ShareModule extends ReactContextBaseJavaModule { if (data != null && data.hasKey("serverUrl")) { ReadableArray files = data.getArray("files"); String serverUrl = data.getString("serverUrl"); - final String token = Credentials.getCredentialsForServerSync(this.getReactApplicationContext(), serverUrl); + final String token = Credentials.getCredentialsForServerSync(mReactContext, serverUrl); JSONObject postData = buildPostObject(data); if (files != null && files.size() > 0) { @@ -236,7 +237,7 @@ public class ShareModule extends ReactContextBaseJavaModule { try (Response response = client.newCall(request).execute()) { if (response.isSuccessful()) { - String responseData = response.body().string(); + String responseData = Objects.requireNonNull(response.body()).string(); JSONObject responseJson = new JSONObject(responseData); JSONArray fileInfoArray = responseJson.getJSONArray("file_infos"); JSONArray file_ids = new JSONArray(); diff --git a/ios/Gekidou/Sources/Gekidou/FileCache.swift b/ios/Gekidou/Sources/Gekidou/Cache/FileCache.swift similarity index 100% rename from ios/Gekidou/Sources/Gekidou/FileCache.swift rename to ios/Gekidou/Sources/Gekidou/Cache/FileCache.swift diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift new file mode 100644 index 0000000000..7933f2fb6a --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift @@ -0,0 +1,13 @@ +import Foundation + +extension ImageCache { + func image(for userId: String, updatedAt: Double, onServer serverUrl: String) -> Data? { + lock.lock(); defer { lock.unlock() } + let key = "\(serverUrl)-\(userId)-\(updatedAt)" as NSString + if let image = imageCache.object(forKey: key) as? Data { + return image + } + + return nil + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift new file mode 100644 index 0000000000..78e96df953 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift @@ -0,0 +1,29 @@ +import Foundation + +extension ImageCache { + public func removeAllImages() { + imageCache.removeAllObjects() + keysCache.removeAllObjects() + } + + func insertImage(_ data: Data?, for userId: String, updatedAt: Double, onServer serverUrl: String ) { + guard let data = data else { + return removeImage(for: userId, onServer: serverUrl) + } + + lock.lock(); defer { lock.unlock() } + let cacheKey = "\(serverUrl)-\(userId)" as NSString + let imageKey = "\(cacheKey)-\(updatedAt)" as NSString + imageCache.setObject(NSData(data: data), forKey: imageKey as NSString, cost: data.count) + keysCache.setObject(imageKey, forKey: cacheKey) + } + + func removeImage(for userId: String, onServer serverUrl: String) { + lock.lock(); defer { lock.unlock() } + let cacheKey = "\(serverUrl)-\(userId)" as NSString + if let key = keysCache.object(forKey: cacheKey) { + keysCache.removeObject(forKey: cacheKey) + imageCache.removeObject(forKey: key) + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache.swift new file mode 100644 index 0000000000..4480352027 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache.swift @@ -0,0 +1,31 @@ +import Foundation + +public final class ImageCache: ImageCacheType { + public static let `default` = ImageCache() + struct Config { + let countLimit: Int + let memoryLimit: Int + + static let defaultConfig = Config(countLimit: 50, memoryLimit: 1024 * 1024 * 50) + } + + lazy var imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = config.countLimit + cache.totalCostLimit = config.memoryLimit + return cache + }() + + lazy var keysCache: NSCache = { + let cache = NSCache() + cache.countLimit = config.countLimit + return cache + }() + + let lock = NSLock() + let config: Config + + private init(config: Config = Config.defaultConfig) { + self.config = config + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift new file mode 100644 index 0000000000..2d4f875442 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift @@ -0,0 +1,11 @@ +import Foundation + +protocol ImageCacheType: class { + func image(for userId: String, updatedAt: Double, onServer serverUrl: String) -> Data? + + func insertImage(_ data: Data?, for userId: String, updatedAt: Double, onServer serverUrl: String ) + + func removeImage(for userId: String, onServer serverUrl: String) + + func removeAllImages() +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift index b73452c693..480430da80 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift @@ -24,8 +24,8 @@ extension Network { return request(url, withMethod: "POST", withBody: data, withHeaders: nil, withServerUrl: serverUrl, completionHandler: completionHandler) } - public func fetchUserProfilePicture(userId: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { - let endpoint = "/users/\(userId)/image" + public func fetchUserProfilePicture(userId: String, lastUpdateAt: Double, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + let endpoint = "/users/\(userId)/image?lastPictureUpdate=\(lastUpdateAt)" 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 415bc7b246..f500237a8d 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift @@ -98,10 +98,11 @@ extension Network { } public func fetchProfileImageSync(_ serverUrl: String, senderId: String, overrideIconUrl: String?, completionHandler: @escaping (_ data: Data?) -> Void) { + var updatedAt: Double = 0 func processResponse(data: Data?, response: URLResponse?, error: Error?) { if let httpResponse = response as? HTTPURLResponse { if (httpResponse.statusCode == 200 && error == nil) { - FileCache.default.saveProfileImage(serverUrl: serverUrl, userId: senderId, imageData: data) + ImageCache.default.insertImage(data, for: senderId, updatedAt: updatedAt, onServer: serverUrl) completionHandler(data) } else { os_log( @@ -118,12 +119,16 @@ extension Network { let url = URL(string: overrideUrl) { request(url, withMethod: "GET", withServerUrl: "", completionHandler: processResponse) } else { - if let image = FileCache.default.getProfileImage(serverUrl: serverUrl, userId: senderId) { + if let lastUpdateAt = Database.default.getUserLastPictureAt(for: senderId, withServerUrl: serverUrl) { + updatedAt = lastUpdateAt + } + if let image = ImageCache.default.image(for: senderId, updatedAt: updatedAt, onServer: serverUrl) { os_log(OSLogType.default, "Mattermost Notifications: cached image") - completionHandler(image.pngData()) + completionHandler(image) } else { + ImageCache.default.removeImage(for: senderId, onServer: serverUrl) os_log(OSLogType.default, "Mattermost Notifications: image not cached") - fetchUserProfilePicture(userId: senderId, withServerUrl: serverUrl, completionHandler: processResponse) + fetchUserProfilePicture(userId: senderId, lastUpdateAt: updatedAt, withServerUrl: serverUrl, completionHandler: processResponse) } } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift index 0e318851eb..0ac07971c6 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift @@ -12,14 +12,14 @@ import SQLite public struct User: Codable, Hashable { let id: String let auth_service: String - let update_at: Int64 - let delete_at: Int64 + let update_at: Double + let delete_at: Double let email: String let first_name: String let is_bot: Bool let is_guest: Bool let last_name: String - let last_picture_update: Int64 + let last_picture_update: Double let locale: String let nickname: String let position: String @@ -54,36 +54,36 @@ public struct User: Codable, Hashable { let container = try decoder.container(keyedBy: UserKeys.self) id = try container.decode(String.self, forKey: .id) auth_service = try container.decode(String.self, forKey: .auth_service) - update_at = try container.decode(Int64.self, forKey: .update_at) - delete_at = try container.decode(Int64.self, forKey: .delete_at) + update_at = (try? container.decodeIfPresent(Double.self, forKey: .update_at)) ?? 0 + delete_at = (try? container.decodeIfPresent(Double.self, forKey: .delete_at)) ?? 0 email = try container.decode(String.self, forKey: .email) first_name = try container.decode(String.self, forKey: .first_name) is_bot = container.contains(.is_bot) ? try container.decode(Bool.self, forKey: .is_bot) : false roles = try container.decode(String.self, forKey: .roles) is_guest = roles.contains("system_guest") last_name = try container.decode(String.self, forKey: .last_name) - last_picture_update = try container.decodeIfPresent(Int64.self, forKey: .last_picture_update) ?? 0 + last_picture_update = (try? container.decodeIfPresent(Double.self, forKey: .last_picture_update)) ?? 0 locale = try container.decode(String.self, forKey: .locale) nickname = try container.decode(String.self, forKey: .nickname) position = try container.decode(String.self, forKey: .position) status = "offline" username = try container.decode(String.self, forKey: .username) - let notifyPropsData = try container.decodeIfPresent([String: String].self, forKey: .notify_props) + let notifyPropsData = try? container.decodeIfPresent([String: String].self, forKey: .notify_props) if (notifyPropsData != nil) { notify_props = Database.default.json(from: notifyPropsData) ?? "{}" } else { notify_props = "{}" } - let propsData = try container.decodeIfPresent([String: String].self, forKey: .props) + let propsData = try? container.decodeIfPresent([String: String].self, forKey: .props) if (propsData != nil) { props = Database.default.json(from: propsData) ?? "{}" } else { props = "{}" } - let timezoneData = try container.decodeIfPresent([String: String].self, forKey: .timezone) + let timezoneData = try? container.decodeIfPresent([String: String].self, forKey: .timezone) if (timezoneData != nil) { timezone = Database.default.json(from: timezoneData) ?? "{}" } else { @@ -119,6 +119,24 @@ extension Database { throw DatabaseError.NoResults(query.expression.description) } + + public func getUserLastPictureAt(for userId: String, withServerUrl serverUrl: String) -> Double? { + let idCol = Expression("id") + var updateAt: Double? + do { + let db = try getDatabaseForServer(serverUrl) + + let stmtString = "SELECT * FROM User WHERE id='\(userId)'" + + let results: [User] = try db.prepareRowIterator(stmtString).map {try $0.decode()} + updateAt = results.first?.last_picture_update + + } catch { + return nil + } + + return updateAt + } public func queryUsers(byIds: Set, withServerUrl: String) throws -> Set { let db = try getDatabaseForServer(withServerUrl) @@ -180,14 +198,14 @@ extension Database { var setter = [Setter]() setter.append(id <- user.id) setter.append(authService <- user.auth_service) - setter.append(updateAt <- user.update_at) - setter.append(deleteAt <- user.delete_at) + setter.append(updateAt <- Int64(user.update_at)) + setter.append(deleteAt <- Int64(user.delete_at)) setter.append(email <- user.email) setter.append(firstName <- user.first_name) setter.append(isBot <- user.is_bot) setter.append(isGuest <- user.is_guest) setter.append(lastName <- user.last_name) - setter.append(lastPictureUpdate <- user.last_picture_update) + setter.append(lastPictureUpdate <- Int64(user.last_picture_update)) setter.append(locale <- user.locale) setter.append(nickname <- user.nickname) setter.append(position <- user.position) diff --git a/ios/MattermostShare/ViewModels/ShareViewModel.swift b/ios/MattermostShare/ViewModels/ShareViewModel.swift index ee643381b3..eb5a753932 100644 --- a/ios/MattermostShare/ViewModels/ShareViewModel.swift +++ b/ios/MattermostShare/ViewModels/ShareViewModel.swift @@ -91,7 +91,7 @@ class ShareViewModel: ObservableObject { return } - Gekidou.Network.default.fetchUserProfilePicture(userId: userId, withServerUrl: serverUrl, completionHandler: {data, response, error in + Gekidou.Network.default.fetchUserProfilePicture(userId: userId, lastUpdateAt: 0, withServerUrl: serverUrl, completionHandler: {data, response, error in guard (response as? HTTPURLResponse)?.statusCode == 200 else { debugPrint("Error while fetching image \(String(describing: (response as? HTTPURLResponse)?.statusCode))") return diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 700472a58e..a5d6e305c6 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -89,9 +89,11 @@ class NotificationService: UNNotificationServiceExtension { let isCRTEnabled = notification.userInfo["is_crt_enabled"] as? Bool ?? false let rootId = notification.userInfo["root_id"] as? String ?? "" - let senderId = notification.userInfo["sender_id"] as? String ?? "" let channelName = notification.userInfo["channel_name"] as? String ?? "" let message = (notification.userInfo["message"] as? String ?? "") + let overrideUsername = notification.userInfo["override_username"] as? String + let senderId = notification.userInfo["sender_id"] as? String + let senderIdentifier = overrideUsername ?? senderId let avatar = INImage(imageData: imgData) as INImage? var conversationId = channelId @@ -99,7 +101,7 @@ class NotificationService: UNNotificationServiceExtension { conversationId = rootId } - let handle = INPersonHandle(value: senderId, type: .unknown) + let handle = INPersonHandle(value: senderIdentifier, type: .unknown) let sender = INPerson(personHandle: handle, nameComponents: nil, displayName: channelName, @@ -140,18 +142,22 @@ class NotificationService: UNNotificationServiceExtension { func sendMessageIntent(notification: UNNotificationContent) { if #available(iOSApplicationExtension 15.0, *) { + let overrideUsername = notification.userInfo["override_username"] as? String + let senderId = notification.userInfo["sender_id"] as? String + let sender = overrideUsername ?? senderId + guard let serverUrl = notification.userInfo["server_url"] as? String, - let senderId = notification.userInfo["sender_id"] as? String + let sender = sender else { os_log(OSLogType.default, "Mattermost Notifications: No intent created. will call contentHandler to present notification") self.contentHandler?(notification) return } - os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, senderId) let overrideIconUrl = notification.userInfo["override_icon_url"] as? String + os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, sender) - Network.default.fetchProfileImageSync(serverUrl, senderId: senderId, overrideIconUrl: overrideIconUrl) {[weak self] data in + Network.default.fetchProfileImageSync(serverUrl, senderId: sender, overrideIconUrl: overrideIconUrl) {[weak self] data in self?.sendMessageIntentCompletion(notification, data) } }