diff --git a/android/app/build.gradle b/android/app/build.gradle index b780ccdd9..f425e0240 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -224,6 +224,7 @@ configurations.all { dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2' //noinspection GradleDynamicVersio implementation "com.facebook.react:react-native:+" // From node_modules 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 3aa2b92ed..7e4d45e74 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -89,10 +89,6 @@ public class CustomPushNotificationHelper { } } -// if (serverUrl == null) { - message = "Unknown Server\n" + message; -// } - messagingStyle.addMessage(message, timestamp, sender.build()); } } diff --git a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.java b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.java deleted file mode 100644 index 6ce141a60..000000000 --- a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.mattermost.helpers; - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; - -import com.nozbe.watermelondb.Database; - -public class DatabaseHelper { - private static final String DEFAULT_DATABASE_NAME = "app.db"; - private static Database defaultDatabase; - - private static void setDefaultDatabase(Context context) { - String databaseName = Uri.fromFile(context.getFilesDir()).toString() + "/" + DEFAULT_DATABASE_NAME; - defaultDatabase = new Database(databaseName, context); - } - - public static String getOnlyServerUrl(Context context) { - if (defaultDatabase == null) { - setDefaultDatabase(context); - } - - String emptyArray[] = {}; - String query = "SELECT url FROM Servers"; - Cursor cursor = defaultDatabase.rawQuery(query, emptyArray); - - if (cursor.getCount() == 1) { - cursor.moveToFirst(); - return cursor.getString(0); - } - - return null; - } -} diff --git a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt new file mode 100644 index 000000000..7d5564eda --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt @@ -0,0 +1,525 @@ +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.JSONObject +import java.lang.Exception +import java.util.* + +class DatabaseHelper { + var defaultDatabase: Database? = null + private set + + val onlyServerUrl: String? + get() { + val query = "SELECT url FROM Servers WHERE last_active_at != 0" + val cursor = defaultDatabase!!.rawQuery(query) + if (cursor.count == 1) { + cursor.moveToFirst() + val url = cursor.getString(0) + cursor.close() + return url + } + return null + } + + fun init(context: Context) { + if (defaultDatabase == null) { + setDefaultDatabase(context) + } + } + + 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) { + return null + } + } + + fun getDatabaseForServer(context: Context?, serverUrl: String): Database? { + 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!!) + } + return null + } + + fun queryIds(db: Database, tableName: String, ids: Array): List { + val list: MutableList = ArrayList() + val args = TextUtils.join(",", Arrays.stream(ids).map { value: String? -> "?" }.toArray()) + try { + db.rawQuery("select distinct id from $tableName where id IN ($args)", ids as Array).use { cursor -> + if (cursor.count > 0) { + while (cursor.moveToNext()) { + list.add(cursor.getString(cursor.getColumnIndex("id"))) + } + } + } + } 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 { value: Any? -> "?" }.toArray()) + try { + db.rawQuery("select distinct $columnName from $tableName where $columnName IN ($args)", values).use { cursor -> + if (cursor.count > 0) { + while (cursor.moveToNext()) { + list.add(cursor.getString(cursor.getColumnIndex(columnName))) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return list + } + + fun queryCurrentUserId(db: Database): String? { + val result = find(db, "System", "currentUserId")!! + return result.getString("value") + } + + fun queryPostSinceForChannel(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 handlePosts(db: Database, postsData: ReadableMap?, channelId: String) { + // 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>() + var earliest = 0.0 + var latest = 0.0 + + if (ordered != null && posts.isNotEmpty()) { + val firstId = ordered.first() + val lastId = ordered.last() + var prevPostId = "" + + val sortedPosts = posts.toList().sortedBy { (_, value) -> + ((value as Map<*, *>).get("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.isNullOrEmpty()) { + post.putIfAbsent("prev_post_id", prevPostId) + } + + if (lastId == key) { + earliest = post.get("create_at") as Double + } else if (firstId == key) { + latest = post.get("create_at") as Double + } + + val jsonPost = JSONObject(post) + val rootId = post.get("root_id") as? String + + if (!rootId.isNullOrEmpty()) { + var thread = postsInThread.get(rootId)?.toMutableList() + if (thread == null) { + thread = mutableListOf() + } + + thread.add(jsonPost) + postsInThread.put(rootId, thread.toList()) + } + + if (find(db, "Post", key) == null) { + insertPost(db, jsonPost) + } else { + updatePost(db, jsonPost) + } + + if (ordered.contains(key)) { + prevPostId = key + } + } + } + } + + handlePostsInChannel(db, channelId, earliest, latest) + handlePostsInThread(db, postsInThread) + } + } + + 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 + } + + + 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"), + user.getDouble("last_picture_update"), + 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? = null + 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? = null + 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 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) + 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"), + file.getInt("height"), + file.getString("mini_preview"), + file.getString("mime_type"), + file.getString("name"), + file.getString("post_id"), + file.getDouble("size"), + file.getInt("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 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 { + when (val value = this[it]) + { + is JSONArray -> + { + 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 + } + } + + companion object { + var instance: DatabaseHelper? = null + get() { + if (field == null) { + field = DatabaseHelper() + } + return field + } + private set + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/Network.java b/android/app/src/main/java/com/mattermost/helpers/Network.java new file mode 100644 index 000000000..930a41cf7 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/Network.java @@ -0,0 +1,67 @@ +package com.mattermost.helpers; + +import android.content.Context; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.ReadableMap; + +import com.mattermost.networkclient.APIClientModule; +import com.mattermost.networkclient.enums.RetryTypes; + +import okhttp3.HttpUrl; + + +public class Network { + private static APIClientModule clientModule; + private static final WritableMap clientOptions = Arguments.createMap(); + private static final Promise emptyPromise = new ResolvePromise(); + + public static void init(Context context) { + final ReactApplicationContext reactContext = new ReactApplicationContext(context); + clientModule = new APIClientModule(reactContext); + createClientOptions(); + } + + public static void get(String baseUrl, String endpoint, ReadableMap options, Promise promise) { + createClientIfNeeded(baseUrl); + clientModule.get(baseUrl, endpoint, options, promise); + } + + public static void post(String baseUrl, String endpoint, ReadableMap options, Promise promise) { + createClientIfNeeded(baseUrl); + clientModule.post(baseUrl, endpoint, options, promise); + } + + private static void createClientOptions() { + WritableMap headers = Arguments.createMap(); + headers.putString("X-Requested-With", "XMLHttpRequest"); + clientOptions.putMap("headers", headers); + + WritableMap retryPolicyConfiguration = Arguments.createMap(); + retryPolicyConfiguration.putString("type", RetryTypes.EXPONENTIAL_RETRY.getType()); + retryPolicyConfiguration.putDouble("retryLimit", 2); + retryPolicyConfiguration.putDouble("exponentialBackoffBase", 2); + retryPolicyConfiguration.putDouble("exponentialBackoffScale", 0.5); + clientOptions.putMap("retryPolicyConfiguration", retryPolicyConfiguration); + + WritableMap requestAdapterConfiguration = Arguments.createMap(); + requestAdapterConfiguration.putString("bearerAuthTokenResponseHeader", "token"); + clientOptions.putMap("requestAdapterConfiguration", requestAdapterConfiguration); + + WritableMap sessionConfiguration = Arguments.createMap(); + sessionConfiguration.putInt("httpMaximumConnectionsPerHost", 10); + sessionConfiguration.putDouble("timeoutIntervalForRequest", 30000); + sessionConfiguration.putDouble("timeoutIntervalForResource", 30000); + clientOptions.putMap("sessionConfiguration", sessionConfiguration); + } + + private static void createClientIfNeeded(String baseUrl) { + HttpUrl url = HttpUrl.parse(baseUrl); + if (url != null && !clientModule.hasClientFor(url)) { + clientModule.createClientFor(baseUrl, clientOptions, emptyPromise); + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt b/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt new file mode 100644 index 000000000..237a50525 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt @@ -0,0 +1,199 @@ +package com.mattermost.helpers + +import android.content.Context +import android.os.Bundle +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.nozbe.watermelondb.Database +import java.io.IOException +import java.util.concurrent.Executors +import kotlin.coroutines.* +import kotlinx.coroutines.* + +class PushNotificationDataHelper(private val context: Context) { + private var scope = Executors.newSingleThreadExecutor() + fun fetchAndStoreDataForPushNotification(initialData: Bundle) { + scope.execute(Runnable { + runBlocking { + PushNotificationDataRunnable.start(context, initialData) + } + }) + } +} + +class PushNotificationDataRunnable { + companion object { + private val specialMentions = listOf("all", "here", "channel") + @Synchronized + suspend fun start(context: Context, initialData: Bundle) { + try { + val serverUrl: String = initialData.getString("server_url") ?: return + val channelId = initialData.getString("channel_id") + val db = DatabaseHelper.instance!!.getDatabaseForServer(context, serverUrl) + + if (db != 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) + posts = postData?.getMap("posts") + userIdsToLoad = postData?.getArray("userIdsToLoad") + usernamesToLoad = postData?.getArray("usernamesToLoad") + + 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 { + if (posts != null && channelId != null) { + DatabaseHelper.instance!!.handlePosts(db, posts!!.getMap("data"), channelId) + } + + if (userIdsToLoad != null && userIdsToLoad!!.size() > 0) { + DatabaseHelper.instance!!.handleUsers(db, userIdsToLoad!!) + } + + if (usernamesToLoad != null && usernamesToLoad!!.size() > 0) { + DatabaseHelper.instance!!.handleUsers(db, usernamesToLoad!!) + } + } + + db.close() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private suspend fun fetchPosts(db: Database, serverUrl: String, channelId: 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") + val queryParams = if (since == null) "?page=0&per_page=60" else "?since=${since.toLong()}" + val endpoint = "/api/v4/channels/$channelId/posts$queryParams" + val postsResponse = fetch(serverUrl, endpoint) + val results = Arguments.createMap() + + if (postsResponse != null) { + val data = ReadableMapUtils.toMap(postsResponse) + results.putMap("posts", postsResponse) + val postsData = data.get("data") as? Map<*, *> + if (postsData != null) { + val postsMap = postsData.get("posts") + if (postsMap != null) { + val posts = ReadableMapUtils.toWritableMap(postsMap as? Map) + val iterator = posts.keySetIterator() + val userIds = mutableListOf() + val usernames = mutableListOf() + 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) + } + } + } + } + + 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 (userIds.size > 0) { + results.putArray("userIdsToLoad", ReadableArrayUtils.toWritableArray(userIds.toTypedArray())) + } + + if (usernames.size > 0) { + results.putArray("usernamesToLoad", ReadableArrayUtils.toWritableArray(usernames.toTypedArray())) + } + } + } + } + + + 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/RandomId.kt b/android/app/src/main/java/com/mattermost/helpers/RandomId.kt new file mode 100644 index 000000000..bebe1f495 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/RandomId.kt @@ -0,0 +1,22 @@ +package com.mattermost.helpers + +import kotlin.math.floor + +class RandomId { + companion object { + private const val alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + private const val alphabetLenght = alphabet.length + private const val idLenght = 16 + + fun generate(): String { + var id = "" + for (i in 1.rangeTo((idLenght / 2))) { + val random = floor(Math.random() * alphabetLenght * alphabetLenght) + id += alphabet[floor(random / alphabetLenght).toInt()] + id += alphabet[(random % alphabetLenght).toInt()] + } + + return id + } + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java b/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java new file mode 100644 index 000000000..eb2e473c8 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/ReadableArrayUtils.java @@ -0,0 +1,125 @@ +package com.mattermost.helpers; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.WritableArray; + +import java.util.Map; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONException; + +public class ReadableArrayUtils { + public static JSONArray toJSONArray(ReadableArray readableArray) throws JSONException { + JSONArray jsonArray = new JSONArray(); + + for (int i = 0; i < readableArray.size(); i++) { + ReadableType type = readableArray.getType(i); + + switch (type) { + case Null: + jsonArray.put(i, null); + break; + case Boolean: + jsonArray.put(i, readableArray.getBoolean(i)); + break; + case Number: + jsonArray.put(i, readableArray.getDouble(i)); + break; + case String: + jsonArray.put(i, readableArray.getString(i)); + break; + case Map: + jsonArray.put(i, ReadableMapUtils.toJSONObject(readableArray.getMap(i))); + break; + case Array: + jsonArray.put(i, ReadableArrayUtils.toJSONArray(readableArray.getArray(i))); + break; + } + } + + return jsonArray; + } + + public static Object[] toArray(JSONArray jsonArray) throws JSONException { + Object[] array = new Object[jsonArray.length()]; + + for (int i = 0; i < jsonArray.length(); i++) { + Object value = jsonArray.get(i); + + if (value instanceof JSONObject) { + value = ReadableMapUtils.toMap((JSONObject) value); + } + if (value instanceof JSONArray) { + value = ReadableArrayUtils.toArray((JSONArray) value); + } + + array[i] = value; + } + + return array; + } + + public static Object[] toArray(ReadableArray readableArray) { + Object[] array = new Object[readableArray.size()]; + + for (int i = 0; i < readableArray.size(); i++) { + ReadableType type = readableArray.getType(i); + + switch (type) { + case Null: + array[i] = null; + break; + case Boolean: + array[i] = readableArray.getBoolean(i); + break; + case Number: + array[i] = readableArray.getDouble(i); + break; + case String: + array[i] = readableArray.getString(i); + break; + case Map: + array[i] = ReadableMapUtils.toMap(readableArray.getMap(i)); + break; + case Array: + array[i] = ReadableArrayUtils.toArray(readableArray.getArray(i)); + break; + } + } + + return array; + } + + public static WritableArray toWritableArray(Object[] array) { + WritableArray writableArray = Arguments.createArray(); + + for (Object value : array) { + if (value == null) { + writableArray.pushNull(); + } + if (value instanceof Boolean) { + writableArray.pushBoolean((Boolean) value); + } + if (value instanceof Double) { + writableArray.pushDouble((Double) value); + } + if (value instanceof Integer) { + writableArray.pushInt((Integer) value); + } + if (value instanceof String) { + writableArray.pushString((String) value); + } + if (value instanceof Map) { + writableArray.pushMap(ReadableMapUtils.toWritableMap((Map) value)); + } + if (value.getClass().isArray()) { + writableArray.pushArray(ReadableArrayUtils.toWritableArray((Object[]) value)); + } + } + + return writableArray; + } +} diff --git a/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java b/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java new file mode 100644 index 000000000..4a25b5012 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/helpers/ReadableMapUtils.java @@ -0,0 +1,135 @@ +package com.mattermost.helpers; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.WritableMap; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +public class ReadableMapUtils { + public static JSONObject toJSONObject(ReadableMap readableMap) throws JSONException { + JSONObject jsonObject = new JSONObject(); + + ReadableMapKeySetIterator iterator = readableMap.keySetIterator(); + + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + ReadableType type = readableMap.getType(key); + + switch (type) { + case Null: + jsonObject.put(key, null); + break; + case Boolean: + jsonObject.put(key, readableMap.getBoolean(key)); + break; + case Number: + jsonObject.put(key, readableMap.getDouble(key)); + break; + case String: + jsonObject.put(key, readableMap.getString(key)); + break; + case Map: + jsonObject.put(key, ReadableMapUtils.toJSONObject(readableMap.getMap(key))); + break; + case Array: + jsonObject.put(key, ReadableArrayUtils.toJSONArray(readableMap.getArray(key))); + break; + } + } + + return jsonObject; + } + + public static Map toMap(JSONObject jsonObject) throws JSONException { + Map map = new HashMap<>(); + Iterator iterator = jsonObject.keys(); + + while (iterator.hasNext()) { + String key = iterator.next(); + Object value = jsonObject.get(key); + + if (value instanceof JSONObject) { + value = ReadableMapUtils.toMap((JSONObject) value); + } + if (value instanceof JSONArray) { + value = ReadableArrayUtils.toArray((JSONArray) value); + } + + map.put(key, value); + } + + return map; + } + + public static Map toMap(ReadableMap readableMap) { + Map map = new HashMap<>(); + ReadableMapKeySetIterator iterator = readableMap.keySetIterator(); + + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + ReadableType type = readableMap.getType(key); + + switch (type) { + case Null: + map.put(key, null); + break; + case Boolean: + map.put(key, readableMap.getBoolean(key)); + break; + case Number: + map.put(key, readableMap.getDouble(key)); + break; + case String: + map.put(key, readableMap.getString(key)); + break; + case Map: + map.put(key, ReadableMapUtils.toMap(readableMap.getMap(key))); + break; + case Array: + map.put(key, ReadableArrayUtils.toArray(readableMap.getArray(key))); + break; + } + } + + return map; + } + + public static WritableMap toWritableMap(Map map) { + WritableMap writableMap = Arguments.createMap(); + Iterator iterator = map.entrySet().iterator(); + + while (iterator.hasNext()) { + Map.Entry pair = (Map.Entry)iterator.next(); + Object value = pair.getValue(); + + if (value == null) { + writableMap.putNull((String) pair.getKey()); + } else if (value instanceof Boolean) { + writableMap.putBoolean((String) pair.getKey(), (Boolean) value); + } else if (value instanceof Double) { + writableMap.putDouble((String) pair.getKey(), (Double) value); + } else if (value instanceof Integer) { + writableMap.putInt((String) pair.getKey(), (Integer) value); + } else if (value instanceof String) { + writableMap.putString((String) pair.getKey(), (String) value); + } else if (value instanceof Map) { + writableMap.putMap((String) pair.getKey(), ReadableMapUtils.toWritableMap((Map) value)); + } else if (value.getClass() != null && value.getClass().isArray()) { + writableMap.putArray((String) pair.getKey(), ReadableArrayUtils.toWritableArray((Object[]) value)); + } + + iterator.remove(); + } + + return writableMap; + } +} 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 f226ec490..536de78f3 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -21,12 +21,13 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import com.mattermost.helpers.CustomPushNotificationHelper; import com.mattermost.helpers.DatabaseHelper; +import com.mattermost.helpers.Network; +import com.mattermost.helpers.PushNotificationDataHelper; import com.mattermost.helpers.ResolvePromise; -import com.wix.reactnativenotifications.core.NotificationIntentAdapter; -import com.wix.reactnativenotifications.core.ProxyService; import com.wix.reactnativenotifications.core.notification.PushNotification; import com.wix.reactnativenotifications.core.AppLaunchHelper; import com.wix.reactnativenotifications.core.AppLifecycleFacade; @@ -43,12 +44,16 @@ public class CustomPushNotification extends PushNotification { private static final String PUSH_TYPE_CLEAR = "clear"; private static final String PUSH_TYPE_SESSION = "session"; private static final String NOTIFICATIONS_IN_CHANNEL = "notificationsInChannel"; + private final PushNotificationDataHelper dataHelper; public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) { super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper); CustomPushNotificationHelper.createNotificationChannels(context); + dataHelper = new PushNotificationDataHelper(context); try { + Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context); + Network.init(context); PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); String version = String.valueOf(pInfo.versionCode); String storedVersion = null; @@ -151,7 +156,9 @@ public class CustomPushNotification extends PushNotification { public void resolve(@Nullable Object value) { if (isIdLoaded) { Bundle response = (Bundle) value; - addServerUrlToBundle(response); + if (value != null) { + addServerUrlToBundle(response); + } } } @@ -169,10 +176,14 @@ public class CustomPushNotification extends PushNotification { if (!mAppLifecycleFacade.isAppVisible()) { if (type.equals(PUSH_TYPE_MESSAGE)) { if (channelId != null) { + if (serverUrl != null) { + dataHelper.fetchAndStoreDataForPushNotification(mNotificationProps.asBundle()); + } + Map> notificationsInChannel = loadNotificationsMap(mContext); List list = notificationsInChannel.get(channelId); if (list == null) { - list = Collections.synchronizedList(new ArrayList(0)); + list = Collections.synchronizedList(new ArrayList<>(0)); } list.add(0, notificationId); @@ -259,7 +270,7 @@ public class CustomPushNotification extends PushNotification { private String addServerUrlToBundle(Bundle bundle) { String serverUrl = bundle.getString("server_url"); if (serverUrl == null) { - serverUrl = DatabaseHelper.getOnlyServerUrl(mContext); + serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getOnlyServerUrl(); bundle.putString("server_url", serverUrl); mNotificationProps = createProps(bundle); } @@ -269,13 +280,13 @@ public class CustomPushNotification extends PushNotification { private static void saveNotificationsMap(Context context, Map> inputMap) { SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE); - if (pSharedPref != null && context != null) { + if (pSharedPref != null) { JSONObject json = new JSONObject(inputMap); String jsonString = json.toString(); SharedPreferences.Editor editor = pSharedPref.edit(); - editor.remove(NOTIFICATIONS_IN_CHANNEL).commit(); + editor.remove(NOTIFICATIONS_IN_CHANNEL).apply(); editor.putString(NOTIFICATIONS_IN_CHANNEL, jsonString); - editor.commit(); + editor.apply(); } } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java index 127e03ee4..e6dfc819b 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java @@ -126,8 +126,10 @@ public class MainApplication extends NavigationApplication implements INotificat super.onCreate(); instance = this; + Context context = getApplicationContext(); + // Delete any previous temp files created by the app - File tempFolder = new File(getApplicationContext().getCacheDir(), RealPathUtil.CACHE_DIR_NAME); + File tempFolder = new File(context.getCacheDir(), RealPathUtil.CACHE_DIR_NAME); RealPathUtil.deleteTempFiles(tempFolder); Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath()); diff --git a/android/build.gradle b/android/build.gradle index 1cfccc653..862cc615a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,8 +8,8 @@ buildscript { targetSdkVersion = 29 ndkVersion = "20.1.5948944" supportLibVersion = "28.0.0" - kotlinVersion = "1.4.30" - kotlin_version = "1.4.30" + kotlinVersion = "1.5.30" + kotlin_version = "1.5.30" firebaseVersion = "21.0.0" RNNKotlinVersion = kotlinVersion } diff --git a/package-lock.json b/package-lock.json index 0f9536435..946025b46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4857,7 +4857,7 @@ }, "node_modules/@mattermost/react-native-network-client": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#52e2f9be66a5af660b7875bfbee6de859e339831", + "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#74cc214aea9e31878bc45f24c6bf31531a6e9b07", "license": "MIT", "dependencies": { "validator": "13.6.0", @@ -36383,7 +36383,7 @@ "requires": {} }, "@mattermost/react-native-network-client": { - "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#52e2f9be66a5af660b7875bfbee6de859e339831", + "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#74cc214aea9e31878bc45f24c6bf31531a6e9b07", "from": "@mattermost/react-native-network-client@github:mattermost/react-native-network-client", "requires": { "validator": "13.6.0",