Skip to content

Conversation

@VelikovPetar
Copy link
Contributor

@VelikovPetar VelikovPetar commented Nov 20, 2025

🎯 Goal

We have reports of crashes originating from the DB caused by performing write operations after the DB has been corrupted. With this PR we are adding several guards and optimistic improvements that would:

  • reduce the possibility of a corruption (parallel writes)
  • catch any exception thrown when trying to write to the closed database
  • recreate the database after a corruption is detected, so that the flows that use the DB are not broken in the same app session

🛠 Implementation details

  • Introduce Mutex locks for write operations on the same table (follows the pattern established in the DatabaseChannelRepository/DatabaseMessageRepository -> scope.launchWithMutext(mutex) { writeOperation() }, which runs the write in a new coroutine, locked on the provided Mutex)
  • Add error handling to these operations, to ensure no crashes happen if the DB still becomes corrupted
  • Observe the database on corruption, and fully recreate it afterwards (Necessary to do, otherwise the DB will be recreated only on the next app launch)
  • Ensure the repositories use the most up-to-date DAOs from the DB. In case of a corruption, the previously retrieved DAOs will stop working, so we need to make sure they are created by the latest instance of the DB. For this purpose, we introduce Recoverable<Entity>Dao classes, which are wrappers around the lazy getDatabase().getDao() which always retrieves a Dao from the current DB instance, instead of keeping a reference to the initially retrieved DAO.

🎨 UI Changes

NA

🧪 Testing

  1. Apply the provided patch. It adds a Corrupt DB button, which will attempt to simulate a DB corruption, and insert a dummy user afterwards.
  2. Click the Corrupt DB button - sometimes multiple attempts are required in order for the DB to become corrupted
  3. Observe the logs for: Chat:StreamSQLiteCallback - onCorruption called for DB it means that a corruption happened
  4. The app should not crash
  5. Any subsequent operations in the app (open channel, write a message... ) should not crash the app
Provide the patch summary here
Subject: [PATCH] Add logs.
---
Index: stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/DatabaseCorruptionUtils.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/DatabaseCorruptionUtils.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/DatabaseCorruptionUtils.kt
new file mode 100644
--- /dev/null	(date 1763653805679)
+++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/DatabaseCorruptionUtils.kt	(date 1763653805679)
@@ -0,0 +1,350 @@
+/*
+ * Copyright (c) 2014-2022 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.offline.repository.database
+
+import android.content.Context
+import android.util.Log
+import io.getstream.chat.android.client.ChatClient
+import io.getstream.chat.android.offline.repository.database.internal.ChatDatabase
+import java.io.File
+import java.io.RandomAccessFile
+import kotlin.random.Random
+
+public fun ChatClient.corruptDatabaseFile(
+    context: Context,
+    userId: String,
+    corruptionSize: Int = 1024,
+): Boolean {
+    return DatabaseCorruptionUtils.corruptDatabaseFile(
+        context = context,
+        userId = userId,
+        corruptionSize = corruptionSize,
+    )
+}
+
+public fun ChatClient.corruptDatabaseHeader(
+    context: Context,
+    userId: String,
+): Boolean {
+    return DatabaseCorruptionUtils.corruptDatabaseHeader(
+        context = context,
+        userId = userId,
+    )
+}
+
+public fun ChatClient.truncateDatabaseFile(
+    context: Context,
+    userId: String,
+    truncateToBytes: Long? = null,
+): Boolean {
+    return DatabaseCorruptionUtils.truncateDatabaseFile(
+        context = context,
+        userId = userId,
+        truncateToBytes = truncateToBytes,
+    )
+}
+
+public fun ChatClient.appendGarbageToDatabase(
+    context: Context,
+    userId: String,
+    garbageSize: Int = 2048,
+): Boolean {
+    return DatabaseCorruptionUtils.appendGarbageToDatabase(
+        context = context,
+        userId = userId,
+        garbageSize = garbageSize,
+    )
+}
+
+/**
+ * Utility class for corrupting the ChatDatabase for testing purposes.
+ *
+ * WARNING: These methods will permanently damage the database!
+ * Only use these for testing error handling and recovery scenarios.
+ */
+internal object DatabaseCorruptionUtils {
+
+    /**
+     * Corrupts the SQLite database by overwriting random bytes in the file.
+     * This simulates file system corruption or incomplete writes.
+     *
+     * @param context Android context
+     * @param userId The user ID whose database should be corrupted
+     * @param corruptionSize Number of bytes to corrupt (default: 1024)
+     * @return true if corruption was successful, false otherwise
+     */
+    fun corruptDatabaseFile(
+        context: Context,
+        userId: String,
+        corruptionSize: Int = 1024,
+    ): Boolean {
+        return try {
+            val dbFile = getDatabaseFile(context, userId)
+            if (!dbFile.exists()) {
+                return false
+            }
+
+            // Close the database if it's open
+            // closeDatabaseInstance(userId)
+
+            RandomAccessFile(dbFile, "rw").use { raf ->
+                val fileSize = raf.length()
+                if (fileSize > corruptionSize) {
+                    // Corrupt random sections of the file
+                    repeat(3) {
+                        Log.d("X_PETAR", "corrupting random section...")
+                        val randomPosition = Random.nextLong(0, fileSize - corruptionSize)
+                        raf.seek(randomPosition)
+                        val corruptedBytes = ByteArray(corruptionSize) { Random.nextInt(256).toByte() }
+                        raf.write(corruptedBytes)
+                    }
+                }
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * Corrupts the SQLite database header (first 100 bytes).
+     * This makes the database completely unreadable by SQLite.
+     *
+     * @param context Android context
+     * @param userId The user ID whose database should be corrupted
+     * @return true if corruption was successful, false otherwise
+     */
+    fun corruptDatabaseHeader(
+        context: Context,
+        userId: String,
+    ): Boolean {
+        return try {
+            val dbFile = getDatabaseFile(context, userId)
+            if (!dbFile.exists()) {
+                return false
+            }
+
+            // Close the database if it's open
+            closeDatabaseInstance(userId)
+
+            RandomAccessFile(dbFile, "rw").use { raf ->
+                Log.d("X_PETAR", "corrupting random section...")
+                // SQLite header is the first 100 bytes
+                // Overwriting it makes the database unrecognizable
+                raf.seek(0)
+                val corruptedHeader = ByteArray(100) { Random.nextInt(256).toByte() }
+                raf.write(corruptedHeader)
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * Truncates the database file, simulating data loss or incomplete writes.
+     *
+     * @param context Android context
+     * @param userId The user ID whose database should be corrupted
+     * @param truncateToBytes The size to truncate the file to. If null, truncates to 50% of original size.
+     * @return true if corruption was successful, false otherwise
+     */
+    fun truncateDatabaseFile(
+        context: Context,
+        userId: String,
+        truncateToBytes: Long? = null,
+    ): Boolean {
+        return try {
+            val dbFile = getDatabaseFile(context, userId)
+            if (!dbFile.exists()) {
+                return false
+            }
+
+            // Close the database if it's open
+            closeDatabaseInstance(userId)
+
+            RandomAccessFile(dbFile, "rw").use { raf ->
+                Log.d("X_PETAR", "corrupting random section...")
+                val originalSize = raf.length()
+                val newSize = truncateToBytes ?: (originalSize / 2)
+                raf.setLength(newSize)
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * Corrupts the database by executing malformed SQL that breaks the schema.
+     * This approach keeps the file intact but makes the database logically corrupted.
+     *
+     * @param database The ChatDatabase instance to corrupt
+     * @return true if corruption was successful, false otherwise
+     */
+    fun corruptDatabaseSchema(database: ChatDatabase): Boolean {
+        return try {
+            database.openHelper.writableDatabase.apply {
+                execSQL("PRAGMA writable_schema = 1")
+
+                // Attempt to corrupt the sqlite_master table (this may fail safely)
+                try {
+                    execSQL("DELETE FROM sqlite_master WHERE type = 'table'")
+                } catch (e: Exception) {
+                    // Try alternative corruption methods
+                    execSQL("PRAGMA user_version = -999")
+                }
+
+                // Drop critical tables without cascade, breaking foreign key constraints
+                try {
+                    execSQL("DROP TABLE IF EXISTS ChannelEntity")
+                } catch (e: Exception) {
+                    // Some tables might have dependencies
+                }
+
+                try {
+                    execSQL("DROP TABLE IF EXISTS MessageInnerEntity")
+                } catch (e: Exception) {
+                    // Some tables might have dependencies
+                }
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * Creates foreign key violations by inserting invalid data.
+     *
+     * @param database The ChatDatabase instance to corrupt
+     * @return true if corruption was successful, false otherwise
+     */
+    fun createForeignKeyViolations(database: ChatDatabase): Boolean {
+        return try {
+            database.openHelper.writableDatabase.apply {
+                // Disable foreign key constraints temporarily
+                execSQL("PRAGMA foreign_keys = 0")
+
+                // Insert orphaned records that violate foreign keys
+                execSQL(
+                    """
+                    INSERT OR IGNORE INTO ReactionEntity (messageId, userId, type, syncStatus, createdAt)
+                    VALUES ('non_existent_message_id', 'fake_user', 'like', 0, 0)
+                    """.trimIndent(),
+                )
+
+                execSQL(
+                    """
+                    INSERT OR IGNORE INTO AttachmentEntity (messageId, authorName, assetUrl)
+                    VALUES ('non_existent_message_id_2', 'fake_author', 'fake_url')
+                    """.trimIndent(),
+                )
+
+                // Re-enable foreign keys (this will make the database inconsistent)
+                execSQL("PRAGMA foreign_keys = 1")
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * Appends random garbage data to the end of the database file.
+     *
+     * @param context Android context
+     * @param userId The user ID whose database should be corrupted
+     * @param garbageSize Amount of garbage data to append in bytes
+     * @return true if corruption was successful, false otherwise
+     */
+    fun appendGarbageToDatabase(
+        context: Context,
+        userId: String,
+        garbageSize: Int = 2048,
+    ): Boolean {
+        return try {
+            val dbFile = getDatabaseFile(context, userId)
+            if (!dbFile.exists()) {
+                return false
+            }
+
+            // Close the database if it's open
+            closeDatabaseInstance(userId)
+
+            RandomAccessFile(dbFile, "rw").use { raf ->
+                raf.seek(raf.length()) // Move to end of file
+                val garbageBytes = ByteArray(garbageSize) { Random.nextInt(256).toByte() }
+                raf.write(garbageBytes)
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * Gets the database file for a given user ID.
+     *
+     * @param context Android context
+     * @param userId The user ID
+     * @return The database file
+     */
+    private fun getDatabaseFile(context: Context, userId: String): File {
+        return context.getDatabasePath("stream_chat_database_$userId").also {
+            Log.d("X_PETAR", "Database file path: ${it.absolutePath}")
+        }
+    }
+
+    /**
+     * Attempts to close and clear the database instance from the singleton map.
+     * This is necessary before performing file-level corruption operations.
+     *
+     * @param userId The user ID whose database should be closed
+     */
+    private fun closeDatabaseInstance(userId: String) {
+        try {
+            // Access the private INSTANCES field using reflection
+            val companionClass = ChatDatabase::class.java.declaredClasses
+                .firstOrNull { it.simpleName == "Companion" }
+
+            companionClass?.let { companion ->
+                val instancesField = companion.getDeclaredField("INSTANCES")
+                instancesField.isAccessible = true
+
+                @Suppress("UNCHECKED_CAST")
+                val instances = instancesField.get(null) as? MutableMap<String, ChatDatabase?>
+
+                instances?.get(userId)?.let { db ->
+                    if (db.isOpen) {
+                        db.close()
+                    }
+                    instances.remove(userId)
+                }
+            }
+        } catch (e: Exception) {
+            // If reflection fails, that's okay - the database might not be open
+            e.printStackTrace()
+        }
+    }
+}
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt	(revision ae5fcd16f269fedb79f1123177443c4815f68775)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt	(date 1763653805643)
@@ -19,6 +19,7 @@
 import android.content.Context
 import android.content.Intent
 import android.os.Bundle
+import android.util.Log
 import android.widget.Toast
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
@@ -37,6 +38,7 @@
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.DrawerValue
 import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
 import androidx.compose.material3.ModalDrawerSheet
 import androidx.compose.material3.ModalNavigationDrawer
 import androidx.compose.material3.Scaffold
@@ -55,6 +57,7 @@
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
 import io.getstream.chat.android.client.ChatClient
 import io.getstream.chat.android.client.api.models.QueryThreadsRequest
 import io.getstream.chat.android.compose.sample.ChatApp
@@ -99,6 +102,8 @@
 import io.getstream.chat.android.models.Thread
 import io.getstream.chat.android.models.User
 import io.getstream.chat.android.models.querysort.QuerySortByField
+import io.getstream.chat.android.offline.repository.database.corruptDatabaseFile
+import io.getstream.chat.android.offline.repository.database.corruptDatabaseHeader
 import io.getstream.chat.android.state.extensions.globalStateFlow
 import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -106,6 +111,7 @@
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.launch
+import java.util.UUID
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class ChannelsActivity : ComponentActivity() {
@@ -208,6 +214,9 @@
                                     onOptionSelected = { selectedTab = it },
                                 )
                             },
+                            floatingActionButton = {
+                                CorruptDatabaseButton()
+                            },
                             containerColor = ChatTheme.colors.appBackground,
                         ) { padding ->
                             Box(modifier = Modifier.padding(padding)) {
@@ -248,6 +257,36 @@
         // MyCustomUi()
     }
 
+    @Composable
+    fun CorruptDatabaseButton() {
+        FloatingActionButton(
+            onClick = {
+                val userId = ChatClient.instance().getCurrentUser()?.id ?: ""
+                Log.d("X_PETAR", "Corrupting database for user: $userId")
+                val corrupted = ChatClient.instance().run {
+                    // appendGarbageToDatabase(this@ChannelsActivity, userId)
+                    corruptDatabaseHeader(this@ChannelsActivity, userId) &&
+                        corruptDatabaseFile(this@ChannelsActivity, userId)
+                }
+                if (corrupted) {
+                    lifecycleScope.launch {
+                        Log.d("X_PETAR", "DB corrupted, trying to write a user")
+                        delay(3000)
+                        val dummyUser = User(id = "dummy_user + ${UUID.randomUUID()}")
+                        ChatHelper.userRepositoryProxy.repository?.insertUsers(listOf(dummyUser))
+                        // ChatHelper.channelRepositoryProxy.repository?.selectChannelsSyncNeeded(30)?.also {
+                        //     Log.d("Chat:DB", "Channels needing sync: $it")
+                        // }
+                    }
+                } else {
+                    Log.d("X_PETAR", "Failed to corrupt DB")
+                }
+            },
+        ) {
+            Text("Corrupt DB")
+        }
+    }
+
     @OptIn(ExperimentalMaterial3Api::class)
     @Composable
     private fun MentionsContent() {
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/service/CustomRepositoryFactoryProvider.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/service/CustomRepositoryFactoryProvider.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/service/CustomRepositoryFactoryProvider.kt
new file mode 100644
--- /dev/null	(date 1763653805647)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/service/CustomRepositoryFactoryProvider.kt	(date 1763653805647)
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
+ *
+ * Licensed under the Stream License;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.getstream.chat.android.compose.sample.service
+
+import io.getstream.chat.android.client.persistance.repository.ChannelRepository
+import io.getstream.chat.android.client.persistance.repository.UserRepository
+import io.getstream.chat.android.client.persistance.repository.factory.RepositoryFactory
+import io.getstream.chat.android.models.Message
+import io.getstream.chat.android.models.User
+import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory
+
+class CustomRepositoryFactoryProvider(
+    val offlinePluginFactory: StreamOfflinePluginFactory,
+    val userRepositoryProxy: UserRepositoryProxy,
+    val channelRepositoryProxy: ChannelRepositoryProxy,
+) : RepositoryFactory.Provider {
+
+    override fun createRepositoryFactory(user: User): RepositoryFactory {
+        val delegate = offlinePluginFactory.createRepositoryFactory(user)
+
+        return object : RepositoryFactory by delegate {
+
+            override fun createUserRepository(): UserRepository {
+                return delegate.createUserRepository().also {
+                    userRepositoryProxy.repository = it
+                }
+            }
+            override fun createChannelRepository(
+                getUser: suspend (String) -> User,
+                getMessage: suspend (String) -> Message?,
+            ): ChannelRepository {
+                return delegate.createChannelRepository(getUser, getMessage).also {
+                    channelRepositoryProxy.repository = it
+                }
+            }
+        }
+    }
+}
+
+class ChannelRepositoryProxy(var repository: ChannelRepository? = null)
+class UserRepositoryProxy(var repository: UserRepository? = null)
Index: stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt
--- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt	(revision ae5fcd16f269fedb79f1123177443c4815f68775)
+++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt	(date 1763653805668)
@@ -24,6 +24,9 @@
 import io.getstream.chat.android.client.notifications.handler.NotificationConfig
 import io.getstream.chat.android.client.notifications.handler.NotificationHandlerFactory
 import io.getstream.chat.android.compose.sample.data.UserCredentials
+import io.getstream.chat.android.compose.sample.service.ChannelRepositoryProxy
+import io.getstream.chat.android.compose.sample.service.CustomRepositoryFactoryProvider
+import io.getstream.chat.android.compose.sample.service.UserRepositoryProxy
 import io.getstream.chat.android.compose.sample.ui.StartupActivity
 import io.getstream.chat.android.models.Channel
 import io.getstream.chat.android.models.EventType
@@ -45,6 +48,11 @@
 
     private const val TAG = "ChatHelper"
 
+    private lateinit var offlinePluginFactory: StreamOfflinePluginFactory
+    private lateinit var repositoryFactoryProvider: CustomRepositoryFactoryProvider
+    public lateinit var userRepositoryProxy: UserRepositoryProxy
+    public lateinit var channelRepositoryProxy: ChannelRepositoryProxy
+
     /**
      * Initializes the SDK with the given API key.
      */
@@ -82,7 +90,15 @@
             },
         )
 
-        val offlinePlugin = StreamOfflinePluginFactory(context)
+        offlinePluginFactory = StreamOfflinePluginFactory(context)
+
+        userRepositoryProxy = UserRepositoryProxy()
+        channelRepositoryProxy = ChannelRepositoryProxy()
+        repositoryFactoryProvider = CustomRepositoryFactoryProvider(
+            offlinePluginFactory,
+            userRepositoryProxy,
+            channelRepositoryProxy,
+        )
 
         val statePluginFactory = StreamStatePluginFactory(
             config = StatePluginConfig(
@@ -96,9 +112,10 @@
 
         ChatClient.Builder(apiKey, context)
             .notifications(notificationConfig, notificationHandler)
-            .withPlugins(offlinePlugin, statePluginFactory)
+            .withPlugins(offlinePluginFactory, statePluginFactory)
             .logLevel(logLevel)
             .uploadAttachmentsNetworkType(UploadAttachmentsNetworkType.NOT_ROAMING)
+            .withRepositoryFactoryProvider(repositoryFactoryProvider)
             .appName("Chat Sample Compose")
             .apply {
                 baseUrl?.let {

Summary by CodeRabbit

  • New Features

    • Added database corruption recovery mechanism to preserve data integrity when database issues occur.
    • Implemented lazy database initialization for improved resource efficiency.
  • Bug Fixes

    • Fixed reaction filtering to properly validate user ownership.
  • Improvements

    • Enhanced concurrent database operation synchronization for better reliability and data consistency.

✏️ Tip: You can customize this high-level summary in your review settings.

@github-actions
Copy link
Contributor

github-actions bot commented Nov 20, 2025

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.25 MB 5.25 MB 0.00 MB 🟢
stream-chat-android-offline 5.48 MB 5.49 MB 0.01 MB 🟢
stream-chat-android-ui-components 10.60 MB 10.60 MB 0.00 MB 🟢
stream-chat-android-compose 12.81 MB 12.81 MB 0.00 MB 🟢

@VelikovPetar VelikovPetar marked this pull request as ready for review November 20, 2025 17:59
@VelikovPetar VelikovPetar requested a review from a team as a code owner November 20, 2025 17:59
@github-actions
Copy link
Contributor

github-actions bot commented Dec 8, 2025

This pull request has been automatically marked as stale because it has been inactive for 14 days. It will be closed in 7 days if no further activity occurs.

@github-actions github-actions bot added the Stale label Dec 8, 2025
@github-actions github-actions bot removed the Stale label Dec 15, 2025
@github-actions
Copy link
Contributor

This pull request has been automatically marked as stale because it has been inactive for 14 days. It will be closed in 7 days if no further activity occurs.

@github-actions github-actions bot added the Stale label Dec 29, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 30, 2025

Walkthrough

The PR introduces database recovery and write-operation synchronization mechanisms. It adds Recoverable*Dao wrappers that lazily delegate to the current ChatDatabase instance, enabling runtime database replacement if corruption occurs. It also adds mutex-based locking to all database write operations and implements SQLite open-helper corruption callbacks for automatic database recreation.

Changes

Cohort / File(s) Summary
Database Corruption Handling & Recovery
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt, StreamOfflinePluginFactory.kt
Added StreamSQLiteOpenHelperFactory and StreamSQLiteCallback to intercept and handle SQLite corruption events with automatic database recreation. Replaced eager initialization with lazy database factory via lambda supplier.
Coroutine Synchronization
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/extensions/CoroutineScopeExtensions.kt
Removed suspend modifier from launchWithMutex extension function; added try/catch for graceful exception handling and logging of IllegalStateException.
Recoverable DAO Wrappers
RecoverableChannelDao.kt, RecoverableChannelConfigDao.kt, RecoverableMessageDao.kt, RecoverablePollDao.kt, RecoverableReplyMessageDao.kt, RecoverableQueryChannelsDao.kt, RecoverableReactionDao.kt, RecoverableSyncStateDao.kt, RecoverableThreadDao.kt, RecoverableThreadOrderDao.kt, RecoverableUserDao.kt
Introduced 11 new lazy-delegating DAO wrappers that resolve the current ChatDatabase instance on each operation, enabling recovery if the underlying database is replaced at runtime.
Repository Write Synchronization
DatabaseChannelConfigRepository.kt, DatabaseMessageRepository.kt, DatabaseQueryChannelsRepository.kt, DatabaseReactionRepository.kt, DatabaseThreadsRepository.kt
Added Mutex-based write-operation locking across all DB mutation paths (insert, update, delete, clear). Wrapped mutations in scope.launchWithMutex or mutex.withLock to ensure exclusive access.
DAO Deprecations
MessageDao.kt, ReplyMessageDao.kt
Marked two methods as @Deprecated: MessageDao.insert(MessageEntity) and upsertMessageInnerEntity; ReplyMessageDao.delete(ReplyMessageInnerEntity).
Factory & Wiring
DatabaseRepositoryFactory.kt
Refactored to accept lazy database provider (() -> ChatDatabase) instead of direct instance. Instantiated all Recoverable*Dao wrappers and updated repository constructor calls with new signatures and parameter orders (scope, now, getters).
Test Updates & Cleanup
BaseDomainTest2.kt, BaseRepositoryFacadeIntegrationTest.kt, MessageRepositoryTests.kt, QueryChannelsImplRepositoryTest.kt, ReactionRepositoryTest.kt
Updated test initialization calls to pass lazy database supplier and new CoroutineScope parameters. Removed MockDatabase.kt utility file.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant DAO as RecoverableDAO
    participant Factory as DAO Factory
    participant DB as ChatDatabase
    participant Helper as SQLiteOpenHelper
    
    Client->>DAO: insert/select/delete
    DAO->>DAO: resolve getDatabase()
    
    alt Database Healthy
        DAO->>DB: access current DAO
        DB->>Helper: execute query
        Helper-->>DB: result
        DB-->>DAO: return data
        DAO-->>Client: return result
    else Database Corrupted
        Helper->>Helper: detect corruption
        Helper->>Factory: onCorrupted()
        Factory->>DB: recreate ChatDatabase
        DB-->>Factory: new instance
        DAO->>DB: retry with new instance
        DB->>Helper: execute on new DB
        Helper-->>DB: result
        DB-->>DAO: return data
        DAO-->>Client: return result
    end
Loading
sequenceDiagram
    participant App
    participant Repo as DatabaseRepository
    participant Scope as CoroutineScope
    participant Mutex
    participant DAO
    
    App->>Repo: insertData()
    Repo->>Repo: scope.launchWithMutex(mutex)
    Repo->>Mutex: acquire lock
    alt Lock Acquired
        Mutex-->>Repo: locked
        Repo->>DAO: insert/update/delete
        DAO-->>Repo: complete
        Repo->>Mutex: release lock
        Mutex-->>Repo: unlocked
        Repo-->>App: success
    else Lock Contention
        Mutex->>Mutex: queue operation
        Mutex->>Mutex: wait for lock
        Mutex-->>Repo: acquire
        Repo->>DAO: insert (serialized)
        DAO-->>Repo: complete
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • gpunto

Poem

🐰 Databases dance with mutex grace,
Corruption fades without a trace—
Recovery blooms from lazy streams,
Each DAO dreams synchronized dreams.
The schema strengthens, locks align,
Resilience now defined! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.96% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main objective of the PR - preventing and recovering from database corruption at runtime.
Description check ✅ Passed The description includes all major sections from the template: Goal (corruption prevention/recovery), Implementation details (Mutex locks, error handling, DB recreation, Recoverable DAOs), Testing instructions with a detailed patch, and explicitly states UI changes as N/A.
✨ Finishing touches
  • 📝 Generate docstrings

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • UTF-8: Entity not found: Issue - Could not find referenced Issue.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/DatabaseThreadsRepository.kt (1)

69-79: Cache updated before fire-and-forget DB write could cause inconsistency.

The cache is updated synchronously (lines 71-73) while the DB write is launched asynchronously via scope.launchWithMutex. If the DB write fails (e.g., during corruption or recovery), the cache will contain data that isn't persisted. Additionally, if the coroutine scope is cancelled, pending writes may be lost while the cache retains the data.

Consider either:

  1. Updating cache inside the mutex-protected block after successful DB write
  2. Documenting that cache may be ahead of DB during transient failures
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/factory/internal/DatabaseRepositoryFactory.kt (1)

63-73: Unsynchronized repository caching can cause duplicate instances in concurrent access.

The repositoriesCache is accessed without synchronization across all create*Repository() methods. When multiple coroutines call these methods concurrently, they may both read null and create separate repository instances, with only the last write persisting. Since repositories maintain internal state (as seen in other repositories using Collections.synchronizedMap and Mutex), duplicates can lead to inconsistent caching behavior.

Use a thread-safe map (e.g., ConcurrentHashMap) or add synchronization to the cache writes across all factory methods.

🧹 Nitpick comments (5)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/DatabaseChannelConfigRepository.kt (3)

58-60: Consider error handling for DB writes on corrupted database.

The mutex prevents concurrent write corruption, but write operations may still fail if the database is corrupted or closed. Consider wrapping DB operations in try-catch to gracefully handle such failures and potentially trigger recovery.

🔎 Example error handling pattern
 mutex.withLock {
-    channelConfigDao.insert(configs.map(ChannelConfig::toEntity))
+    try {
+        channelConfigDao.insert(configs.map(ChannelConfig::toEntity))
+    } catch (e: Exception) {
+        // Log and potentially trigger DB recovery
+    }
 }

68-70: Consider error handling for DB writes on corrupted database.

Similar to insertChannelConfigs, this write operation should handle potential database corruption or closure gracefully.


74-76: Consider error handling for DB writes on corrupted database.

The deleteAll operation should also handle database errors gracefully to prevent crashes during corruption scenarios.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (1)

1-2: Minor: Copyright year inconsistency.

This new file uses copyright year 2022 while other new files in this PR use 2023 or 2024. Consider updating to reflect the actual creation year.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt (1)

148-175: Redundant nested synchronized block in createDb.

Since createDb is only called from within getDatabase's synchronized block (and from onCorrupted which also acquires the same lock), the inner synchronized(this) at line 149 is redundant. While JVM intrinsic locks are reentrant so this won't deadlock, it adds confusion and unnecessary overhead.

🔎 Proposed fix
 private fun createDb(context: Context, userId: String): ChatDatabase {
-    synchronized(this) {
-        val db = Room.databaseBuilder(
-            context.applicationContext,
-            ChatDatabase::class.java,
-            "stream_chat_database_$userId",
-        )
-            .fallbackToDestructiveMigration()
-            .addCallback(
-                object : Callback() {
-                    override fun onOpen(db: SupportSQLiteDatabase) {
-                        db.execSQL("PRAGMA synchronous = 1")
-                    }
-                },
-            )
-            .openHelperFactory(
-                StreamSQLiteOpenHelperFactory(onCorrupted = {
-                    synchronized(this) {
-                        // Re-instantiate the DB if corrupted
-                        INSTANCES.remove(userId)
-                        INSTANCES[userId] = createDb(context, userId)
-                    }
-                }),
-            )
-            .build()
-        return db
-    }
+    val db = Room.databaseBuilder(
+        context.applicationContext,
+        ChatDatabase::class.java,
+        "stream_chat_database_$userId",
+    )
+        .fallbackToDestructiveMigration()
+        .addCallback(
+            object : Callback() {
+                override fun onOpen(db: SupportSQLiteDatabase) {
+                    db.execSQL("PRAGMA synchronous = 1")
+                }
+            },
+        )
+        .openHelperFactory(
+            StreamSQLiteOpenHelperFactory(onCorrupted = {
+                synchronized(this@Companion) {
+                    // Re-instantiate the DB if corrupted
+                    INSTANCES.remove(userId)
+                    INSTANCES[userId] = createDb(context, userId)
+                }
+            }),
+        )
+        .build()
+    return db
 }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 949ffa2 and 5711387.

📒 Files selected for processing (28)
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/extensions/CoroutineScopeExtensions.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/factory/StreamOfflinePluginFactory.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/DatabaseChannelConfigRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/MessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/ReplyMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/DatabaseReactionRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/DatabaseThreadsRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/factory/internal/DatabaseRepositoryFactory.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseDomainTest2.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseRepositoryFacadeIntegrationTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/MessageRepositoryTests.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/utils/MockDatabase.kt
💤 Files with no reviewable changes (1)
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/utils/MockDatabase.kt
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{kt,kts}

📄 CodeRabbit inference engine (AGENTS.md)

Format and apply Kotlin style with Spotless (4 spaces, no wildcard imports, licence headers)

Files:

  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/MessageRepositoryTests.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/MessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/extensions/CoroutineScopeExtensions.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/DatabaseThreadsRepository.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseDomainTest2.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/DatabaseChannelConfigRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/ReplyMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseRepositoryFacadeIntegrationTest.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/DatabaseReactionRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/factory/StreamOfflinePluginFactory.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/factory/internal/DatabaseRepositoryFactory.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt
**/*.kt

📄 CodeRabbit inference engine (AGENTS.md)

**/*.kt: Use @OptIn annotations explicitly; avoid suppressions unless documented
Document public APIs with KDoc, including thread expectations and state notes

Files:

  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/MessageRepositoryTests.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/MessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/extensions/CoroutineScopeExtensions.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/DatabaseThreadsRepository.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseDomainTest2.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/DatabaseChannelConfigRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/ReplyMessageDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseRepositoryFacadeIntegrationTest.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/DatabaseReactionRepository.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/factory/StreamOfflinePluginFactory.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/factory/internal/DatabaseRepositoryFactory.kt
  • stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt
**/src/test/**/*.kt

📄 CodeRabbit inference engine (AGENTS.md)

**/src/test/**/*.kt: Use backtick test names (for example: fun message list filters muted channels()) for readability
Use deterministic tests with runTest + virtual time for concurrency-sensitive logic (uploads, sync, message state)
Keep helper extensions private/internal in test files

Files:

  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/MessageRepositoryTests.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseDomainTest2.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseRepositoryFacadeIntegrationTest.kt
🧠 Learnings (2)
📚 Learning: 2025-12-17T15:00:07.506Z
Learnt from: CR
Repo: GetStream/stream-chat-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-17T15:00:07.506Z
Learning: Applies to **/src/test/**/*.kt : Use deterministic tests with `runTest` + virtual time for concurrency-sensitive logic (uploads, sync, message state)

Applied to files:

  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/MessageRepositoryTests.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt
📚 Learning: 2025-12-17T15:00:07.506Z
Learnt from: CR
Repo: GetStream/stream-chat-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-17T15:00:07.506Z
Learning: Applies to **/stream-chat-android-ui-components/**/*Test.kt : Record Shot baselines when behaviour changes in XML kit UI tests

Applied to files:

  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseDomainTest2.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt
  • stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseRepositoryFacadeIntegrationTest.kt
🧬 Code graph analysis (8)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (9)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (1)
  • getDatabase (30-82)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (1)
  • getDatabase (28-68)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt (1)
  • getDatabase (29-58)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (1)
  • getDatabase (30-78)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (1)
  • getDatabase (28-67)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (7)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt (1)
  • getDatabase (138-146)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (1)
  • getDatabase (32-199)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (1)
  • getDatabase (28-67)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (11)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt (1)
  • getDatabase (138-146)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (1)
  • getDatabase (30-82)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (1)
  • getDatabase (28-68)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (1)
  • getDatabase (32-199)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt (1)
  • getDatabase (29-58)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (1)
  • getDatabase (28-67)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (8)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (1)
  • getDatabase (30-82)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (1)
  • getDatabase (28-68)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (1)
  • getDatabase (32-199)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt (1)
  • getDatabase (29-58)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (1)
  • getDatabase (30-78)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/extensions/CoroutineScopeExtensions.kt (1)
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt (1)
  • launch (310-312)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (11)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt (1)
  • getDatabase (138-146)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (1)
  • getDatabase (30-82)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (1)
  • getDatabase (28-68)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (1)
  • getDatabase (32-199)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt (1)
  • getDatabase (29-58)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (1)
  • getDatabase (30-78)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (1)
  • getDatabase (28-67)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (9)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (1)
  • getDatabase (30-82)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (1)
  • getDatabase (28-68)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt (1)
  • getDatabase (29-58)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (1)
  • getDatabase (30-78)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (1)
  • getDatabase (28-67)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (6)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (1)
  • getDatabase (32-199)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (1)
  • getDatabase (28-44)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)
  • getDatabase (28-56)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)
  • getDatabase (28-44)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: base-android-ci / Run unit tests
  • GitHub Check: base-android-ci / Run static checks
  • GitHub Check: base-android-ci / Build
  • GitHub Check: Detekt
  • GitHub Check: compare-sdk-sizes / Compare SDK sizes
  • GitHub Check: Build / compose apks
🔇 Additional comments (32)
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/ReplyMessageDao.kt (1)

46-48: LGTM!

The deprecation annotation is appropriate for marking this unused method for future removal. The message clearly communicates the intent.

stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/MessageRepositoryTests.kt (1)

107-107: LGTM! Aligns with deterministic testing guidelines.

Passing the explicit testDispatcher to runTest ensures deterministic coroutine execution, consistent with the project's testing guidelines for concurrency-sensitive logic.

stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt (1)

55-55: LGTM! Correctly passes test scope to repository.

The updated constructor properly provides testCoroutines.scope, ensuring the repository's coroutine operations execute in the controlled test environment.

stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ReactionRepositoryTest.kt (1)

57-57: LGTM! Test updated for new repository constructor.

The repository instantiation correctly provides the test scope, DAO, and currentUser lambda, maintaining test coverage with the updated constructor signature.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverablePollDao.kt (1)

28-43: LGTM! Recoverable DAO pattern correctly implemented.

The lazy delegate property ensures all PollDao operations use the current ChatDatabase instance, enabling runtime database replacement after corruption recovery. The pattern is consistent with other Recoverable*Dao implementations across the codebase.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/syncState/internal/RecoverableSyncStateDao.kt (1)

28-43: LGTM! Recoverable DAO pattern correctly implemented.

The lazy delegate pattern ensures SyncStateDao operations always target the current ChatDatabase instance, supporting runtime database recovery. Implementation is consistent with the broader recoverable DAO architecture.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/MessageDao.kt (2)

63-70: Method can be safely removed - no external callers found.

The deprecated upsertMessageInnerEntity method has no external usages. It is only called within RecoverableMessageDao as a delegation, and the batch replacement upsertMessageInnerEntities is already actively used. The method definition can be removed from both MessageDao and RecoverableMessageDao without any migration concerns.


50-52: Update the deprecation plan: the method exists only in the DAO layer.

The deprecated insert(MessageEntity) method is called within RecoverableMessageDao's wrapper delegation (line 52), which is expected since RecoverableMessageDao must implement all MessageDao interface methods. No external call sites exist outside the DAO layer. The method can be safely removed by updating both MessageDao and RecoverableMessageDao together, but no additional fixes are needed elsewhere in the codebase.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/factory/StreamOfflinePluginFactory.kt (1)

86-89: LGTM! Lazy database initialization enables runtime recovery.

The lambda-based database provider allows the repository factory to fetch the current ChatDatabase instance on-demand. Thread safety is properly handled via @Volatile and synchronized blocks with double-checked locking. Per-user database isolation is correctly implemented using user.id as both the map key and part of the database filename. Database corruption is transparently recovered through the StreamSQLiteCallback mechanism, which detects corruption via onCorruption() and automatically re-instantiates the database instance.

stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseRepositoryFacadeIntegrationTest.kt (1)

51-51: LGTM!

The test correctly adapts to the new DatabaseRepositoryFactory signature by passing a lambda { chatDatabase } instead of the direct instance. The lambda captures the chatDatabase reference correctly since it's initialized in setup() before the factory is used.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/DatabaseReactionRepository.kt (2)

55-57: Writes are fire-and-forget, which may not match caller expectations.

launchWithMutex launches a coroutine and returns immediately without waiting for completion. Callers of insertReaction, updateReactionsForMessageByDeletedDate, and deleteReaction may expect these suspend functions to complete the write before returning.

This is inconsistent with clear() (lines 156-158), which uses mutex.withLock and suspends until completion.

If fire-and-forget is intentional for resilience (allowing the caller to continue even if the DB write is pending), consider documenting this behavior. Otherwise, consider awaiting the launched job:

scope.launchWithMutex(mutex) { ... }.join()

32-43: KDoc is clear and the mutex pattern is consistent with the PR's approach.

The documentation properly explains the scope parameter and the repository's purpose. The mutex initialization follows the pattern used across other repositories in this PR.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableReplyMessageDao.kt (1)

29-57: LGTM!

The implementation correctly follows the recoverable DAO pattern established across the codebase. The lazy delegate property ensures each call retrieves the DAO from the current ChatDatabase instance, enabling seamless recovery after database recreation. The deprecated delete method properly preserves its annotation.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadOrderDao.kt (1)

28-43: LGTM!

The implementation correctly follows the established recoverable DAO pattern, matching the structure of RecoverableThreadDao and other recoverable DAOs in the codebase. All ThreadOrderDao interface methods are properly delegated.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/extensions/CoroutineScopeExtensions.kt (1)

42-42: Non-suspending function returns a Job but callers cannot await writes.

Changing from suspend fun to fun means callers receive a Job but the operations complete asynchronously. This is intentional per the PR design, but callers expecting synchronous write semantics should be aware.

The returned Job can be used with .join() if awaiting is needed.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt (3)

130-136: LGTM!

Idiomatic Kotlin style improvement - messageCache[it.id] is equivalent to messageCache.get(it.id) but more concise.


143-152: Good improvement: Poll insertion now within mutex-protected block.

Moving poll insertion inside scope.launchWithMutex(dbMutex) ensures all related DB writes (polls, reply messages, messages) are serialized together, maintaining consistency.


164-175: Draft message operations now use fire-and-forget pattern.

Same pattern as other write operations - insertDraftMessage and deleteDraftMessage launch coroutines and return immediately. Consistent with the PR's approach but worth noting that callers won't know when writes complete.

stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/integration/BaseDomainTest2.kt (1)

181-181: LGTM!

The test correctly adapts to the new DatabaseRepositoryFactory signature by wrapping the database instance in a lambda. The db variable is initialized in setup() before createChatDomain() is called, ensuring the captured reference is valid.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/reaction/internal/RecoverableReactionDao.kt (1)

30-77: LGTM!

The implementation correctly follows the established recoverable DAO pattern. All ReactionDao interface methods are properly delegated to the lazily-resolved delegate, enabling seamless database recovery after corruption.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt (2)

179-200: LGTM!

The StreamSQLiteOpenHelperFactory correctly wraps the delegate factory and injects the custom callback. The configuration builder properly preserves all original settings while swapping in the corruption-aware callback.


208-244: LGTM!

The StreamSQLiteCallback properly delegates all lifecycle methods to the original callback and invokes the onCorrupted handler after the delegate's onCorruption completes. The logging is appropriately placed for debugging.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/RecoverableUserDao.kt (1)

28-66: LGTM!

The RecoverableUserDao correctly implements the lazy delegation pattern. The computed delegate property ensures each operation uses the current database instance, enabling seamless recovery after DB recreation. The KDoc clearly explains the purpose and thread-safety expectations.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/RecoverableQueryChannelsDao.kt (1)

28-43: LGTM!

The RecoverableQueryChannelsDao follows the established recoverable DAO pattern consistently. The implementation correctly delegates all operations to the current database instance.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt (1)

63-67: LGTM!

The clear() method correctly uses mutex.withLock to ensure the delete operation completes before returning, and the lock prevents concurrent modifications.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/RecoverableChannelConfigDao.kt (1)

28-67: LGTM!

The RecoverableChannelConfigDao consistently follows the established recoverable DAO pattern. All methods correctly delegate to the current database instance's DAO.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/factory/internal/DatabaseRepositoryFactory.kt (2)

55-61: LGTM!

The factory constructor correctly accepts a lazy database provider () -> ChatDatabase. This enables runtime database replacement without requiring repository recreation, as the Recoverable*Dao wrappers will always obtain the current database instance.


142-161: LGTM!

The createMessageRepository correctly wires multiple Recoverable*Dao instances (MessageDao, ReplyMessageDao, PollDao), ensuring all message-related DAOs support database recovery.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/DatabaseThreadsRepository.kt (1)

164-170: LGTM!

The clear() method correctly evicts the cache first, then uses mutex.withLock to ensure both DAO delete operations complete before returning. This guarantees a consistent cleared state.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/threads/internal/RecoverableThreadDao.kt (1)

28-55: LGTM!

The RecoverableThreadDao follows the established recoverable DAO pattern consistently, correctly delegating all thread operations to the current database instance.

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/internal/RecoverableChannelDao.kt (1)

23-82: LGTM! Clean implementation of the recoverable DAO pattern.

The lazy delegate resolution via the getter property ensures that each operation uses the current ChatDatabase instance, enabling seamless recovery after database recreation. The implementation is consistent with other recoverable DAO wrappers in this PR (e.g., RecoverablePollDao, RecoverableSyncStateDao, RecoverableThreadDao).

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/RecoverableMessageDao.kt (1)

32-199: LGTM! Comprehensive delegation with correct deprecation handling.

The implementation correctly forwards all MessageDao operations to the lazily-resolved delegate. The @Suppress("TooManyFunctions") annotation is appropriate given the large interface surface area. Deprecated methods properly carry forward their annotations.

Note: The non-suspend methods (deleteAttachments, deleteAttachmentsChunked, updateMessageInnerEntity) are inherited from the MessageDao interface. If these are called from a non-IO context, they could block the calling thread — but that concern is orthogonal to this wrapper and should be addressed at the interface level if needed.

Comment on lines +35 to +47
* Any [Exception] thrown during execution is caught and logged. We assume such exceptions
* can happen only in the edge-case where the database is corrupted/closed, so we can safely ignore them.
*
* @param mutex The [Mutex] to acquire before executing the block.
* @param block The suspend function to execute within the mutex lock.
* @return A [kotlinx.coroutines.Job] representing the launched coroutine.
*/
internal fun CoroutineScope.launchWithMutex(mutex: Mutex, block: suspend () -> Unit) = launch {
try {
mutex.withLock { block() }
} catch (e: IllegalStateException) {
logger.e(e) { "Exception in launchWithMutex: ${e.message}" }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Documentation states "Any Exception" but code only catches IllegalStateException.

The KDoc at line 35-36 states "Any [Exception] thrown during execution is caught and logged", but the implementation at line 45 only catches IllegalStateException. Other exceptions (e.g., SQLiteException, SQLiteDatabaseCorruptException) would propagate uncaught.

If the intent is to catch all DB-related exceptions, consider broadening the catch or updating the documentation to match the actual behavior:

Option 1: Catch broader exception types
     try {
         mutex.withLock { block() }
-    } catch (e: IllegalStateException) {
+    } catch (e: Exception) {
+        if (e is kotlinx.coroutines.CancellationException) throw e // Don't suppress cancellation
         logger.e(e) { "Exception in launchWithMutex: ${e.message}" }
     }
Option 2: Update documentation to match implementation
- * Any [Exception] thrown during execution is caught and logged. We assume such exceptions
+ * Any [IllegalStateException] thrown during execution is caught and logged. We assume such exceptions
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* Any [Exception] thrown during execution is caught and logged. We assume such exceptions
* can happen only in the edge-case where the database is corrupted/closed, so we can safely ignore them.
*
* @param mutex The [Mutex] to acquire before executing the block.
* @param block The suspend function to execute within the mutex lock.
* @return A [kotlinx.coroutines.Job] representing the launched coroutine.
*/
internal fun CoroutineScope.launchWithMutex(mutex: Mutex, block: suspend () -> Unit) = launch {
try {
mutex.withLock { block() }
} catch (e: IllegalStateException) {
logger.e(e) { "Exception in launchWithMutex: ${e.message}" }
}
* Any [Exception] thrown during execution is caught and logged. We assume such exceptions
* can happen only in the edge-case where the database is corrupted/closed, so we can safely ignore them.
*
* @param mutex The [Mutex] to acquire before executing the block.
* @param block The suspend function to execute within the mutex lock.
* @return A [kotlinx.coroutines.Job] representing the launched coroutine.
*/
internal fun CoroutineScope.launchWithMutex(mutex: Mutex, block: suspend () -> Unit) = launch {
try {
mutex.withLock { block() }
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e // Don't suppress cancellation
logger.e(e) { "Exception in launchWithMutex: ${e.message}" }
}
Suggested change
* Any [Exception] thrown during execution is caught and logged. We assume such exceptions
* can happen only in the edge-case where the database is corrupted/closed, so we can safely ignore them.
*
* @param mutex The [Mutex] to acquire before executing the block.
* @param block The suspend function to execute within the mutex lock.
* @return A [kotlinx.coroutines.Job] representing the launched coroutine.
*/
internal fun CoroutineScope.launchWithMutex(mutex: Mutex, block: suspend () -> Unit) = launch {
try {
mutex.withLock { block() }
} catch (e: IllegalStateException) {
logger.e(e) { "Exception in launchWithMutex: ${e.message}" }
}
* Any [IllegalStateException] thrown during execution is caught and logged. We assume such exceptions
* can happen only in the edge-case where the database is corrupted/closed, so we can safely ignore them.
*
* @param mutex The [Mutex] to acquire before executing the block.
* @param block The suspend function to execute within the mutex lock.
* @return A [kotlinx.coroutines.Job] representing the launched coroutine.
*/
internal fun CoroutineScope.launchWithMutex(mutex: Mutex, block: suspend () -> Unit) = launch {
try {
mutex.withLock { block() }
} catch (e: IllegalStateException) {
logger.e(e) { "Exception in launchWithMutex: ${e.message}" }
}

Comment on lines 138 to 146
fun getDatabase(context: Context, userId: String): ChatDatabase {
if (!INSTANCES.containsKey(userId)) {
synchronized(this) {
val db = Room.databaseBuilder(
context.applicationContext,
ChatDatabase::class.java,
"stream_chat_database_$userId",
).fallbackToDestructiveMigration()
.addCallback(
object : Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
db.execSQL("PRAGMA synchronous = 1")
}
},
)
.build()
val db = createDb(context, userId)
INSTANCES[userId] = db
}
}
return INSTANCES[userId] ?: error("DB not created")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition in getDatabase() due to check-then-act pattern.

The containsKey check at line 139 is outside the synchronized block, so two threads can both pass the check and enter the synchronized block sequentially, potentially creating duplicate database instances (the second one overwriting the first).

🔎 Proposed fix
 fun getDatabase(context: Context, userId: String): ChatDatabase {
-    if (!INSTANCES.containsKey(userId)) {
-        synchronized(this) {
+    synchronized(this) {
+        if (!INSTANCES.containsKey(userId)) {
             val db = createDb(context, userId)
             INSTANCES[userId] = db
         }
     }
     return INSTANCES[userId] ?: error("DB not created")
 }
🤖 Prompt for AI Agents
In
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt
around lines 138-146, the current check-then-act using
INSTANCES.containsKey(userId) happens outside the synchronized block which
allows a race where two threads both pass the check and create duplicate DB
instances; move the existence check inside the synchronized(this) block so the
check-and-create are atomic (or replace the pattern with a thread-safe atomic
operation such as using INSTANCES.putIfAbsent / getOrPut on a
ConcurrentHashMap), i.e. enter synchronized first, then if the instance for
userId is missing create it and store it, and finally return the instance.

Comment on lines 333 to 337
private fun Message.filterReactions(): Message = copy(
ownReactions = ownReactions
.filter { it.deletedAt == null }
.filter { currentUser == null || it.userId == currentUser.id },
.filter { it.userId == currentUser.id },
latestReactions = latestReactions.filter { it.deletedAt == null },
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find User class definition to check id field constraints
fd -e kt -e kts | xargs rg "data class User|class User" -A 20 | head -100

Repository: GetStream/stream-chat-android

Length of output: 16033


🏁 Script executed:

#!/bin/bash
# Check the DatabaseMessageRepository constructor and currentUser parameter
rg "class DatabaseMessageRepository" -A 10 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt

Repository: GetStream/stream-chat-android

Length of output: 559


🏁 Script executed:

#!/bin/bash
# Find how DatabaseMessageRepository is instantiated
fd -e kt -e kts | xargs rg "DatabaseMessageRepository\(" -B 2 -A 5 | head -150

Repository: GetStream/stream-chat-android

Length of output: 8728


🏁 Script executed:

#!/bin/bash
# Search for the User domain class definition
fd -e kt -e kts | xargs rg "^data class User|^class User" -B 2 -A 30 | grep -A 30 "^[^-]*:.*data class User\|^[^-]*:.*class User" | head -150

Repository: GetStream/stream-chat-android

Length of output: 55


🏁 Script executed:

#!/bin/bash
# More specific search for User class in common locations
fd "User.kt" -e kt | xargs cat | head -100

Repository: GetStream/stream-chat-android

Length of output: 3844


🏁 Script executed:

#!/bin/bash
# Search for User class references specifically in the models directory
find . -path "*/model*" -name "*.kt" | xargs rg "data class User\|class User" -A 20 | head -150

Repository: GetStream/stream-chat-android

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Search for User class in models package
find . -name "*.kt" | xargs rg "package io\.getstream\.chat\.android\.models" -A 50 | grep -B 5 -A 40 "class User\|data class User" | head -200

Repository: GetStream/stream-chat-android

Length of output: 16625


🏁 Script executed:

#!/bin/bash
# Try a different approach - look for User.kt file
find . -name "User.kt" -type f | head -5

Repository: GetStream/stream-chat-android

Length of output: 572


🏁 Script executed:

#!/bin/bash
# Search for where User is defined
rg "class User\(" -t kt -A 25 | head -150

Repository: GetStream/stream-chat-android

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Get the complete User class definition
cat stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt | head -80

Repository: GetStream/stream-chat-android

Length of output: 3608


🏁 Script executed:

#!/bin/bash
# Now let's look at the filterReactions function more carefully
cat -n stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt | sed -n '330,350p'

Repository: GetStream/stream-chat-android

Length of output: 679


🏁 Script executed:

#!/bin/bash
# Check if there's any prior defensive null-checking for currentUser.id
rg "currentUser\\.id|currentUser\\?" stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt -n

Repository: GetStream/stream-chat-android

Length of output: 129


🏁 Script executed:

#!/bin/bash
# Check the DatabaseRepositoryFactory to see how currentUser is provided
cat -n stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/factory/internal/DatabaseRepositoryFactory.kt | sed -n '1,100p'

Repository: GetStream/stream-chat-android

Length of output: 6462


🏁 Script executed:

#!/bin/bash
# Look for any validation of currentUser.id in the codebase
rg "currentUser.*id.*empty|currentUser.*id.*blank|User.*id.*validation" -t kt | head -20

Repository: GetStream/stream-chat-android

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Check if there's any validation when a User is created or connected
rg "require.*id|check.*id" stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt -A 2 -B 2

Repository: GetStream/stream-chat-android

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Find where DatabaseRepositoryFactory is created/instantiated
rg "DatabaseRepositoryFactory\(" -A 5 -B 2 --type-list | head -50

Repository: GetStream/stream-chat-android

Length of output: 462


🏁 Script executed:

#!/bin/bash
# Search for any validation or checks on currentUser before it's used
rg "User\(.*id.*=" stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt | head -5

Repository: GetStream/stream-chat-android

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Check if there are any tests that verify empty id behavior
rg "User\(id = \"\"" --type-list | head -20

Repository: GetStream/stream-chat-android

Length of output: 462


🏁 Script executed:

#!/bin/bash
# Look for tests related to filterReactions
rg "filterReactions" -A 10 -B 2 | head -100

Repository: GetStream/stream-chat-android

Length of output: 23733


Ensure currentUser.id is validated as non-empty before filtering reactions.

The User class allows id to default to "" (empty string) with no validation constraint. The new implementation at line 336 assumes currentUser.id is always valid by directly comparing it.userId == currentUser.id. If currentUser has an empty id in production, reactions with userId = "" would incorrectly match the filter. Either validate that currentUser.id is non-empty when the repository is initialized, or add a defensive check to skip filtering if currentUser.id is empty/blank.

🤖 Prompt for AI Agents
In
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/message/internal/DatabaseMessageRepository.kt
around lines 333 to 337, the filtering uses currentUser.id directly which can be
empty; update the logic to first check currentUser.id is not blank before
applying the userId equality filter and otherwise skip the userId check (i.e.,
filter only by deletedAt when currentUser.id is blank), or alternatively add
validation at repository initialization to ensure currentUser.id is non-empty
and throw/log if invalid; implement the defensive check so reactions with userId
== "" are not accidentally treated as the current user.

Comment on lines 47 to 51
override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) {
queryChannelsDao.insert(toEntity(queryChannelsSpec))
scope.launchWithMutex(mutex) {
queryChannelsDao.insert(toEntity(queryChannelsSpec))
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

insertQueryChannels is fire-and-forget but declared as suspend fun.

Using scope.launchWithMutex launches a coroutine that runs asynchronously—the function returns immediately without waiting for the insert to complete. This is inconsistent with the suspend modifier which typically implies the operation completes before returning. Callers may expect the data to be persisted after this function returns.

If fire-and-forget is intentional for performance, consider documenting this behavior. Otherwise, you should await the result.

🔎 Proposed fix (if awaiting is desired)
 override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) {
-    scope.launchWithMutex(mutex) {
-        queryChannelsDao.insert(toEntity(queryChannelsSpec))
-    }
+    mutex.withLock {
+        queryChannelsDao.insert(toEntity(queryChannelsSpec))
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) {
queryChannelsDao.insert(toEntity(queryChannelsSpec))
scope.launchWithMutex(mutex) {
queryChannelsDao.insert(toEntity(queryChannelsSpec))
}
}
override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) {
mutex.withLock {
queryChannelsDao.insert(toEntity(queryChannelsSpec))
}
}
🤖 Prompt for AI Agents
In
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt
around lines 47–51, the suspend function insertQueryChannels currently launches
a fire-and-forget coroutine (scope.launchWithMutex) so it returns before the
insert completes; instead, make the function actually suspend until the insert
finishes by acquiring the mutex and performing the DAO insert directly (e.g.,
use mutex.withLock { queryChannelsDao.insert(toEntity(queryChannelsSpec)) } or
run the insert inside an appropriate withContext(Dispatchers.IO) block while
holding the mutex), so callers can rely on persistence when the suspend function
returns.

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
29.3% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants