diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 43c37e757..9304a435d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.core.database import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabase.OpenParams import android.database.sqlite.SQLiteDatabaseCorruptException import me.rhunk.snapenhance.common.database.DatabaseObject import me.rhunk.snapenhance.common.database.impl.ConversationMessage @@ -9,12 +10,11 @@ import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.database.impl.StoryEntry import me.rhunk.snapenhance.common.database.impl.UserConversationLink +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull import me.rhunk.snapenhance.common.util.ktx.getInteger import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.manager.Manager -import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -import java.io.File class DatabaseAccess( @@ -25,51 +25,32 @@ class DatabaseAccess( private inline fun SQLiteDatabase.performOperation(crossinline query: SQLiteDatabase.() -> T?): T? { return runCatching { - query() + synchronized(this) { + query() + } }.onFailure { context.log.error("Database operation failed", it) }.getOrNull() } - private var hasShownDatabaseError = false - - private fun showDatabaseError(databasePath: String, throwable: Throwable) { - if (hasShownDatabaseError) return - hasShownDatabaseError = true - context.runOnUiThread { - if (context.mainActivity == null) return@runOnUiThread - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle("SnapEnhance") - .setMessage("Failed to query $databasePath database!\n\n${throwable.localizedMessage}\n\nRestarting Snapchat may fix this issue. If the issue persists, try to clean the app data and cache.") - .setPositiveButton("Restart Snapchat") { _, _ -> - File(databasePath).takeIf { it.exists() }?.delete() - context.softRestartApp() - } - .setNegativeButton("Dismiss") { dialog, _ -> - dialog.dismiss() - }.show() - } - } - private fun SQLiteDatabase.safeRawQuery(query: String, args: Array? = null): Cursor? { return runCatching { rawQuery(query, args) }.onFailure { if (it !is SQLiteDatabaseCorruptException) { context.log.error("Failed to execute query $query", it) - showDatabaseError(this.path, it) return@onFailure } - context.log.warn("Database ${this.path} is corrupted!") + context.longToast("Database ${this.path} is corrupted! Restarting ...") context.androidContext.deleteDatabase(this.path) - showDatabaseError(this.path, it) + context.crash("Database ${this.path} is corrupted!", it) }.getOrNull() } private val dmOtherParticipantCache by lazy { (arroyoDb?.performOperation { safeRawQuery( - "SELECT client_conversation_id, user_id FROM user_conversation WHERE conversation_type = 0 AND user_id != ?", + "SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?", arrayOf(myUserId) )?.use { query -> val participants = mutableMapOf() @@ -77,7 +58,13 @@ class DatabaseAccess( return@performOperation null } do { - participants[query.getStringOrNull("client_conversation_id")!!] = query.getStringOrNull("user_id")!! + val conversationId = query.getStringOrNull("client_conversation_id") ?: continue + val userId = query.getStringOrNull("user_id") ?: continue + participants[conversationId] = when (query.getIntOrNull("conversation_type")) { + 0 -> userId + else -> null + } + participants[userId] = null } while (query.moveToNext()) participants } @@ -89,13 +76,16 @@ class DatabaseAccess( if (!dbPath.exists()) return null return runCatching { SQLiteDatabase.openDatabase( - dbPath.absolutePath, - null, - SQLiteDatabase.OPEN_READONLY or SQLiteDatabase.NO_LOCALIZED_COLLATORS + dbPath, + OpenParams.Builder() + .setOpenFlags(SQLiteDatabase.OPEN_READONLY) + .setErrorHandler { + context.androidContext.deleteDatabase(dbPath.absolutePath) + context.softRestartApp() + }.build() ) }.onFailure { context.log.error("Failed to open database $fileName!", it) - showDatabaseError(dbPath.absolutePath, it) }.getOrNull() } @@ -137,6 +127,7 @@ class DatabaseAccess( } val myUserId by lazy { + context.androidContext.getSharedPreferences("user_session_shared_pref", 0).getString("key_user_id", null) ?: arroyoDb?.performOperation { safeRawQuery(buildString { append("SELECT value FROM required_values WHERE key = 'USERID'") @@ -146,7 +137,7 @@ class DatabaseAccess( } query.getStringOrNull("value")!! } - } ?: context.androidContext.getSharedPreferences("user_session_shared_pref", 0).getString("key_user_id", null)!! + }!! } fun getFeedEntryByConversationId(conversationId: String): FriendFeedEntry? { @@ -241,8 +232,8 @@ class DatabaseAccess( participants.add(query.getStringOrNull("user_id")!!) } while (query.moveToNext()) participants.firstOrNull { it != myUserId } - } - }.also { dmOtherParticipantCache[conversationId] = it } + }.also { dmOtherParticipantCache[conversationId] = it } + } } @@ -253,18 +244,28 @@ class DatabaseAccess( } fun getConversationParticipants(conversationId: String): List? { + if (dmOtherParticipantCache[conversationId] != null) return dmOtherParticipantCache[conversationId]?.let { listOf(myUserId, it) } return arroyoDb?.performOperation { safeRawQuery( - "SELECT user_id FROM user_conversation WHERE client_conversation_id = ?", + "SELECT user_id, conversation_type FROM user_conversation WHERE client_conversation_id = ?", arrayOf(conversationId) - )?.use { - if (!it.moveToFirst()) { + )?.use { cursor -> + if (!cursor.moveToFirst()) { return@performOperation null } val participants = mutableListOf() + var conversationType = -1 do { - participants.add(it.getStringOrNull("user_id")!!) - } while (it.moveToNext()) + if (conversationType == -1) conversationType = cursor.getInteger("conversation_type") + participants.add(cursor.getStringOrNull("user_id")!!) + } while (cursor.moveToNext()) + + if (!dmOtherParticipantCache.containsKey(conversationId)) { + dmOtherParticipantCache[conversationId] = when (conversationType) { + 0 -> participants.firstOrNull { it != myUserId } + else -> null + } + } participants } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt index 9c08f40d9..6c233452f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt @@ -18,11 +18,13 @@ class EventBus( private val subscribers = mutableMapOf, MutableMap>>() fun subscribe(event: KClass, listener: IListener, priority: Int? = null) { - if (!subscribers.containsKey(event)) { - subscribers[event] = sortedMapOf() + synchronized(subscribers) { + if (!subscribers.containsKey(event)) { + subscribers[event] = sortedMapOf() + } + val lastSubscriber = subscribers[event]?.keys?.lastOrNull() ?: 0 + subscribers[event]?.put(priority ?: (lastSubscriber + 1), listener) } - val lastSubscriber = subscribers[event]?.keys?.lastOrNull() ?: 0 - subscribers[event]?.put(priority ?: (lastSubscriber + 1), listener) } inline fun subscribe(event: KClass, priority: Int? = null, crossinline listener: (T) -> Unit) = subscribe(event, { true }, priority, listener) @@ -43,7 +45,9 @@ class EventBus( } fun unsubscribe(event: KClass, listener: IListener) { - subscribers[event]?.values?.remove(listener) + synchronized(subscribers) { + subscribers[event]?.values?.remove(listener) + } } fun post(event: T, afterBlock: T.() -> Unit = {}): T? { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt index 9671a4db0..cd7f89973 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.core.features.impl.ui -import android.annotation.SuppressLint import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect @@ -9,9 +8,14 @@ import android.graphics.drawable.shapes.Shape import android.text.TextPaint import android.view.View import android.view.ViewGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption @@ -21,25 +25,65 @@ import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getId import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import java.util.WeakHashMap import kotlin.math.absoluteValue -@SuppressLint("DiscouragedApi") class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) } + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val setting get() = context.config.userInterface.friendFeedMessagePreview + private val hasE2EE get() = context.config.experimental.e2eEncryption.globalState == true + private val sigColorTextPrimary by lazy { context.mainActivity!!.theme.obtainStyledAttributes( intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr")) ).getColor(0, 0) } + private val cachedLayouts = WeakHashMap() + private val messageCache = EvictingMap>(100) private val friendNameCache = EvictingMap(100) + private suspend fun fetchMessages(conversationId: String, callback: suspend () -> Unit) { + val messages = context.database.getMessagesFromConversationId(conversationId, setting.amount.get().absoluteValue)?.mapNotNull { message -> + val messageContainer = + message.messageContent + ?.let { ProtoReader(it) } + ?.followPath(4, 4)?.let { messageReader -> + takeIf { hasE2EE }?.let takeIf@{ + endToEndEncryption.tryDecryptMessage( + senderId = message.senderId ?: return@takeIf null, + clientMessageId = message.clientMessageId.toLong(), + conversationId = message.clientConversationId ?: return@takeIf null, + contentType = ContentType.fromId(message.contentType), + messageBuffer = messageReader.getBuffer() + ).second + }?.let { ProtoReader(it) } ?: messageReader + } + ?: return@mapNotNull null + + val messageString = messageContainer.getString(2, 1) + ?: ContentType.fromMessageContainer(messageContainer)?.name + ?: return@mapNotNull null + + val friendName = friendNameCache.getOrPut(message.senderId ?: return@mapNotNull null) { + context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" + } + "$friendName: $messageString" + }?.takeIf { it.isNotEmpty() }?.reversed() + + withContext(Dispatchers.Main) { + messages?.also { messageCache[conversationId] = it } ?: run { + messageCache.remove(conversationId) + } + callback() + } + } + override fun onActivityCreate() { - val setting = context.config.userInterface.friendFeedMessagePreview if (setting.globalState != true) return - val hasE2EE = context.config.experimental.e2eEncryption.globalState == true - val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) } - val ffItemId = context.resources.getId("ff_item") val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat() @@ -54,71 +98,65 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams textSize = secondaryTextSize } + context.event.subscribe(BuildMessageEvent::class) { param -> + val conversationId = param.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val cachedView = cachedLayouts[conversationId] ?: return@subscribe + context.coroutineScope.launch { + fetchMessages(conversationId) { + cachedView.postInvalidateDelayed(100L) + } + } + } + context.event.subscribe(BindViewEvent::class) { param -> param.friendFeedItem { conversationId -> val frameLayout = param.view as ViewGroup val ffItem = frameLayout.findViewById(ffItemId) - ffItem.layoutParams = ffItem.layoutParams.apply { - height = ViewGroup.LayoutParams.MATCH_PARENT - } - frameLayout.removeForegroundDrawable("ffItem") - - val stringMessages = context.database.getMessagesFromConversationId(conversationId, setting.amount.get().absoluteValue)?.mapNotNull { message -> - val messageContainer = - message.messageContent - ?.let { ProtoReader(it) } - ?.followPath(4, 4)?.let { messageReader -> - takeIf { hasE2EE }?.let takeIf@{ - endToEndEncryption.tryDecryptMessage( - senderId = message.senderId ?: return@takeIf null, - clientMessageId = message.clientMessageId.toLong(), - conversationId = message.clientConversationId ?: return@takeIf null, - contentType = ContentType.fromId(message.contentType), - messageBuffer = messageReader.getBuffer() - ).second - }?.let { ProtoReader(it) } ?: messageReader - } - ?: return@mapNotNull null - - val messageString = messageContainer.getString(2, 1) - ?: ContentType.fromMessageContainer(messageContainer)?.name - ?: return@mapNotNull null - - val friendName = friendNameCache.getOrPut(message.senderId ?: return@mapNotNull null) { - context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" + context.coroutineScope.launch(coroutineDispatcher) { + withContext(Dispatchers.Main) { + cachedLayouts.remove(conversationId) + frameLayout.removeForegroundDrawable("ffItem") } - "$friendName: $messageString" - }?.reversed() ?: return@friendFeedItem - - var maxTextHeight = 0 - val previewContainerHeight = stringMessages.sumOf { msg -> - val rect = Rect() - textPaint.getTextBounds(msg, 0, msg.length, rect) - rect.height().also { - if (it > maxTextHeight) maxTextHeight = it - }.plus(separatorHeight) - } - ffItem.layoutParams = ffItem.layoutParams.apply { - height = feedEntryHeight + previewContainerHeight + separatorHeight - } + fetchMessages(conversationId) { + var maxTextHeight = 0 + val previewContainerHeight = messageCache[conversationId]?.sumOf { msg -> + val rect = Rect() + textPaint.getTextBounds(msg, 0, msg.length, rect) + rect.height().also { + if (it > maxTextHeight) maxTextHeight = it + }.plus(separatorHeight) + } ?: run { + ffItem.layoutParams = ffItem.layoutParams.apply { + height = ViewGroup.LayoutParams.MATCH_PARENT + } + return@fetchMessages + } - frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { - override fun draw(canvas: Canvas, paint: Paint) { - val offsetY = canvas.height.toFloat() - previewContainerHeight - - stringMessages.forEachIndexed { index, messageString -> - paint.textSize = secondaryTextSize - paint.color = sigColorTextPrimary - canvas.drawText(messageString, - feedEntryHeight + ffSdlPrimaryTextStartMargin, - offsetY + index * maxTextHeight, - paint - ) + ffItem.layoutParams = ffItem.layoutParams.apply { + height = feedEntryHeight + previewContainerHeight + separatorHeight } + + cachedLayouts[conversationId] = frameLayout + + frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + val offsetY = canvas.height.toFloat() - previewContainerHeight + + messageCache[conversationId]?.forEachIndexed { index, messageString -> + paint.textSize = secondaryTextSize + paint.color = sigColorTextPrimary + canvas.drawText(messageString, + feedEntryHeight + ffSdlPrimaryTextStartMargin, + offsetY + index * maxTextHeight, + paint + ) + } + } + })) } - })) + } } } }