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 {

@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.47 MB 5.48 MB 0.01 MB 🟢
stream-chat-android-ui-components 10.58 MB 10.58 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
@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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants