diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt index fb6c362734..690995ba77 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt @@ -10,11 +10,14 @@ package io.element.android.libraries.matrix.impl import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.impl.core.SdkBackgroundTaskError import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.util.anonymizedTokens import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.ClientSessionDelegate @@ -23,6 +26,7 @@ import timber.log.Timber import uniffi.matrix_sdk_common.BackgroundTaskFailureReason import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds private val loggerTag = LoggerTag("RustClientSessionDelegate") @@ -36,6 +40,7 @@ private val loggerTag = LoggerTag("RustClientSessionDelegate") class RustClientSessionDelegate( private val sessionStore: SessionStore, private val appCoroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, coroutineDispatchers: CoroutineDispatchers, ) : ClientSessionDelegate, ClientDelegate { // Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts @@ -122,8 +127,18 @@ class RustClientSessionDelegate( } override fun onBackgroundTaskErrorReport(taskName: String, error: BackgroundTaskFailureReason) { - // TODO actually implement the missing logic to report to sentry and crash the app - Timber.tag(loggerTag.value).e("onBackgroundTaskErrorReport(taskName=$taskName, error=$error)") + val backgroundTaskError = SdkBackgroundTaskError(taskName, error) + Timber.e(backgroundTaskError, "SDK background task failed") + analyticsService.trackError(backgroundTaskError) + + if (error is BackgroundTaskFailureReason.Panic) { + appCoroutineScope.launch { + // The SDK failed in an unrecoverable way, so it will have indeterminate behaviour now. + // Crash the app instead after a small delay to send the error. + delay(500.milliseconds) + throw backgroundTaskError + } + } } override fun retrieveSessionFromKeychain(userId: String): Session { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 34907ab781..85516ab762 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -66,7 +66,12 @@ class RustMatrixClientFactory( private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, private val workManagerScheduler: WorkManagerScheduler, ) { - private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers) + private val sessionDelegate = RustClientSessionDelegate( + sessionStore = sessionStore, + appCoroutineScope = appCoroutineScope, + analyticsService = analyticsService, + coroutineDispatchers = coroutineDispatchers + ) suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) { val client = getBaseClientBuilder( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/SdkBackgroundTaskError.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/SdkBackgroundTaskError.kt new file mode 100644 index 0000000000..39b8709e0f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/SdkBackgroundTaskError.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.core + +import uniffi.matrix_sdk_common.BackgroundTaskFailureReason + +/** + * Error thrown when a background SDK task panics and can't recover. + * @param task The name of the task that failed. + * @param reason The cause of this error. + */ +class SdkBackgroundTaskError( + task: String, + reason: BackgroundTaskFailureReason, +) : Error() { + override val message: String = run { + val message = when (reason) { + is BackgroundTaskFailureReason.EarlyTermination -> "Early termination" + is BackgroundTaskFailureReason.Error -> "Error: ${reason.error}" + is BackgroundTaskFailureReason.Panic -> buildString { + append("Panic (unrecoverable): ") + reason.message?.let { append(it) } + reason.panicBacktrace?.let { + append("\n") + append(it) + } + } + } + "SDK background task '$task' failure: \n$message" + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt index 7c63348298..6aa3ef0e5b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt @@ -9,16 +9,20 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.core.SdkBackgroundTaskError import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +import uniffi.matrix_sdk_common.BackgroundTaskFailureReason @OptIn(ExperimentalCoroutinesApi::class) class RustClientSessionDelegateTest { @@ -44,12 +48,37 @@ class RustClientSessionDelegateTest { assertThat(result!!.accessToken).isEqualTo("at") assertThat(result.refreshToken).isEqualTo("rt") } + + @Test + fun `onBackgroundTaskErrorReport reports the error to analytics if recoverable`() = runTest { + val analyticsService = FakeAnalyticsService(isEnabled = true) + val sut = aRustClientSessionDelegate(analyticsService = analyticsService) + sut.onBackgroundTaskErrorReport("Crasher", BackgroundTaskFailureReason.EarlyTermination) + sut.onBackgroundTaskErrorReport("Crasher", BackgroundTaskFailureReason.Error("BOOM")) + + assertThat(analyticsService.trackedErrors).hasSize(2) + assertThat(analyticsService.trackedErrors[0].message).isEqualTo("SDK background task 'Crasher' failure: \nEarly termination") + assertThat(analyticsService.trackedErrors[1].message).isEqualTo("SDK background task 'Crasher' failure: \nError: BOOM") + } + + @Test(expected = SdkBackgroundTaskError::class) + fun `onBackgroundTaskErrorReport reports the error to analytics and throws it if it's a panic`() = runTest { + val analyticsService = FakeAnalyticsService(isEnabled = true) + val sut = aRustClientSessionDelegate(analyticsService = analyticsService) + sut.onBackgroundTaskErrorReport("Crasher", BackgroundTaskFailureReason.Panic("BOOM", "Stacktrace")) + + assertThat(analyticsService.trackedErrors).hasSize(1) + assertThat(analyticsService.trackedErrors[0].message) + .isEqualTo("SDK background task 'Crasher' failure: \nPanic (unrecoverable): BOOM\nStacktrace") + } } fun TestScope.aRustClientSessionDelegate( sessionStore: SessionStore = InMemorySessionStore(), + analyticsService: AnalyticsService = FakeAnalyticsService(), ) = RustClientSessionDelegate( sessionStore = sessionStore, appCoroutineScope = this, + analyticsService = analyticsService, coroutineDispatchers = testCoroutineDispatchers(), )