diff --git a/README.md b/README.md index 593c76cdc6..62a33091e3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ElementX Android is a [Matrix](https://matrix.org/) Android Client provided by [Element](https://element.io/). -The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 5+. The UI layer is written using Jetpack compose. +The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using Jetpack compose. diff --git a/app/src/main/kotlin/io/element/android/x/di/MatrixClientsHolder.kt b/app/src/main/kotlin/io/element/android/x/di/MatrixClientsHolder.kt index 22dfe8a3ef..24ec04cbce 100644 --- a/app/src/main/kotlin/io/element/android/x/di/MatrixClientsHolder.kt +++ b/app/src/main/kotlin/io/element/android/x/di/MatrixClientsHolder.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.MatrixClient import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrix.core.UserId import kotlinx.coroutines.runBlocking import timber.log.Timber import java.util.concurrent.ConcurrentHashMap @@ -57,13 +58,13 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: @Suppress("DEPRECATION") fun restore(savedInstanceState: Bundle?) { if (savedInstanceState == null || sessionIdsToMatrixClient.isNotEmpty()) return - val sessionIds = savedInstanceState.getSerializable(SAVE_INSTANCE_KEY) as? Array - if (sessionIds.isNullOrEmpty()) return + val userIds = savedInstanceState.getSerializable(SAVE_INSTANCE_KEY) as? Array + if (userIds.isNullOrEmpty()) return // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. runBlocking { - sessionIds.forEach { sessionId -> - Timber.v("Restore matrix session: $sessionId") - val matrixClient = authenticationService.restoreSession(sessionId) + userIds.forEach { userId -> + Timber.v("Restore matrix session: $userId") + val matrixClient = authenticationService.restoreSession(userId) if (matrixClient != null) { add(matrixClient) } diff --git a/app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt b/app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt index 37fd284b3f..b989077b48 100644 --- a/app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt +++ b/app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrix.core.UserId import io.element.android.x.di.MatrixClientsHolder import io.element.android.x.root.RootPresenter import io.element.android.x.root.RootView @@ -89,7 +90,7 @@ class RootFlowNode( } private fun switchToLoggedInFlow(sessionId: SessionId) { - backstack.safeRoot(NavTarget.LoggedInFlow(sessionId = sessionId)) + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId)) } private fun switchToLogoutFlow() { @@ -98,19 +99,19 @@ class RootFlowNode( } private suspend fun tryToRestoreLatestSession( - onSuccess: (SessionId) -> Unit = {}, + onSuccess: (UserId) -> Unit = {}, onFailure: () -> Unit = {} ) { - val latestKnownSessionId = authenticationService.getLatestSessionId() - if (latestKnownSessionId == null) { + val latestKnownUserId = authenticationService.getLatestSessionId() + if (latestKnownUserId == null) { onFailure() return } - if (matrixClientsHolder.knowSession(latestKnownSessionId)) { - onSuccess(latestKnownSessionId) + if (matrixClientsHolder.knowSession(latestKnownUserId)) { + onSuccess(latestKnownUserId) return } - val matrixClient = authenticationService.restoreSession(latestKnownSessionId) + val matrixClient = authenticationService.restoreSession(UserId(latestKnownUserId.value)) if (matrixClient == null) { Timber.v("Failed to restore session...") onFailure() diff --git a/changelog.d/84.feature b/changelog.d/84.feature new file mode 100644 index 0000000000..7cbcaa26cc --- /dev/null +++ b/changelog.d/84.feature @@ -0,0 +1 @@ +Store session data in a secure storage. diff --git a/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootScreen.kt b/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootScreen.kt index 740b147bbd..a4619c1ebe 100644 --- a/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootScreen.kt +++ b/features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootScreen.kt @@ -58,7 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.OutlinedTextField import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.R as StringR @@ -68,7 +68,7 @@ fun LoginRootScreen( state: LoginRootState, modifier: Modifier = Modifier, onChangeServer: () -> Unit = {}, - onLoginWithSuccess: (SessionId) -> Unit = {}, + onLoginWithSuccess: (UserId) -> Unit = {}, ) { val eventSink = state.eventSink Box( diff --git a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt index 3fa20ae93a..d3e18acb0e 100644 --- a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt +++ b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt @@ -22,7 +22,6 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.A_HOMESERVER import io.element.android.libraries.matrixtest.A_HOMESERVER_2 import io.element.android.libraries.matrixtest.A_PASSWORD @@ -88,7 +87,7 @@ class LoginRootPresenterTest { val submitState = awaitItem() assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) val loggedInState = awaitItem() - assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(SessionId(A_SESSION_ID))) + assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID)) } } diff --git a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt index e84226b3e8..f00ede65db 100644 --- a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt +++ b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt @@ -23,8 +23,9 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrixtest.A_SESSION_ID import io.element.android.libraries.matrixtest.A_THROWABLE +import io.element.android.libraries.matrixtest.A_USER_ID import io.element.android.libraries.matrixtest.FakeMatrixClient import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -34,7 +35,7 @@ class LogoutPreferencePresenterTest { @Test fun `present - initial state`() = runTest { val presenter = LogoutPreferencePresenter( - FakeMatrixClient(SessionId("sessionId")), + FakeMatrixClient(A_SESSION_ID), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -47,7 +48,7 @@ class LogoutPreferencePresenterTest { @Test fun `present - logout`() = runTest { val presenter = LogoutPreferencePresenter( - FakeMatrixClient(SessionId("sessionId")), + FakeMatrixClient(A_SESSION_ID), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -63,7 +64,7 @@ class LogoutPreferencePresenterTest { @Test fun `present - logout with error`() = runTest { - val matrixClient = FakeMatrixClient(SessionId("sessionId")) + val matrixClient = FakeMatrixClient(A_SESSION_ID) val presenter = LogoutPreferencePresenter( matrixClient, ) diff --git a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt index a36e7729a6..bdd35fa039 100644 --- a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt +++ b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.MatrixClient +import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrix.room.RoomSummary import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -107,14 +108,14 @@ class RoomListPresenter @Inject constructor( val userDisplayName = client.loadUserDisplayName().getOrNull() val avatarData = AvatarData( - id = client.userId().value, + id = client.sessionId.value, name = userDisplayName, url = userAvatarUrl, size = AvatarSize.SMALL ) matrixUser.value = MatrixUser( - id = client.userId(), - username = userDisplayName ?: client.userId().value, + id = UserId(client.sessionId.value), + username = userDisplayName ?: client.sessionId.value, avatarData = avatarData, ) } diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 743fd2d888..5ea1636d3f 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -26,12 +26,12 @@ import io.element.android.features.roomlist.model.RoomListEvents import io.element.android.features.roomlist.model.RoomListRoomSummary import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.AN_AVATAR_URL import io.element.android.libraries.matrixtest.AN_EXCEPTION import io.element.android.libraries.matrixtest.A_MESSAGE import io.element.android.libraries.matrixtest.A_ROOM_ID import io.element.android.libraries.matrixtest.A_ROOM_NAME +import io.element.android.libraries.matrixtest.A_SESSION_ID import io.element.android.libraries.matrixtest.A_USER_ID import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.FakeMatrixClient @@ -46,9 +46,7 @@ class RoomListPresenterTests { @Test fun `present - should start with no user and then load user with success`() = runTest { val presenter = RoomListPresenter( - FakeMatrixClient( - SessionId("sessionId") - ), + FakeMatrixClient(A_SESSION_ID), createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { @@ -69,7 +67,7 @@ class RoomListPresenterTests { fun `present - should start with no user and then load user with error`() = runTest { val presenter = RoomListPresenter( FakeMatrixClient( - SessionId("sessionId"), + A_SESSION_ID, userDisplayName = Result.failure(AN_EXCEPTION), userAvatarURLString = Result.failure(AN_EXCEPTION), ), @@ -90,9 +88,7 @@ class RoomListPresenterTests { @Test fun `present - should filter room with success`() = runTest { val presenter = RoomListPresenter( - FakeMatrixClient( - SessionId("sessionId") - ), + FakeMatrixClient(A_SESSION_ID), createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { @@ -112,7 +108,7 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = SessionId("sessionId"), + sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter() @@ -138,7 +134,7 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = SessionId("sessionId"), + sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter() @@ -169,7 +165,7 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = SessionId("sessionId"), + sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f7f809c67..0e9c073b20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ jsoup = "1.15.3" appyx = "1.0.3" dependencycheck = "7.4.4" stem = "2.2.3" +sqldelight = "1.5.5" # DI dagger = "2.44.2" @@ -69,6 +70,7 @@ androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt androidx_lifecycle_compose = { module = "androidx.lifecycle:compose", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx_splash = "androidx.core:core-splashscreen:1.0.0" +androidx_security_crypto = "androidx.security:security-crypto:1.0.0" androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" } androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" } @@ -119,6 +121,11 @@ appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.2" +sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.3" +sqlite = "androidx.sqlite:sqlite:2.3.0" # Di inject = "javax.inject:javax.inject:1" @@ -149,3 +156,4 @@ stemlibrary = { id = "com.likethesalad.stem-library", version.ref = "stem" } paparazzi = "app.cash.paparazzi:1.2.0" sonarqube = "org.sonarqube:3.5.0.2730" kover = "org.jetbrains.kotlinx.kover:0.6.1" +sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" } diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt index 2706343e6b..31b58085ba 100644 --- a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt @@ -39,17 +39,17 @@ class DateFormatters @Inject constructor( private val onlyTimeFormatter: DateTimeFormatter by lazy { val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm" - DateTimeFormatter.ofPattern(pattern) + DateTimeFormatter.ofPattern(pattern, locale) } private val dateWithMonthFormatter: DateTimeFormatter by lazy { val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM" - DateTimeFormatter.ofPattern(pattern) + DateTimeFormatter.ofPattern(pattern, locale) } private val dateWithYearFormatter: DateTimeFormatter by lazy { val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy" - DateTimeFormatter.ofPattern(pattern) + DateTimeFormatter.ofPattern(pattern, locale) } internal fun formatTime(localDateTime: LocalDateTime): String { diff --git a/libraries/encrypted-db/build.gradle.kts b/libraries/encrypted-db/build.gradle.kts new file mode 100644 index 0000000000..71ccfe65f7 --- /dev/null +++ b/libraries/encrypted-db/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.encrypteddb" +} + +dependencies { + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.androidx.security.crypto) +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt new file mode 100644 index 0000000000..5258c388c5 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.element.encrypteddb + +import android.content.Context +import com.squareup.sqldelight.android.AndroidSqliteDriver +import com.squareup.sqldelight.db.SqlDriver +import io.element.encrypteddb.passphrase.PassphraseProvider +import net.sqlcipher.database.SupportFactory + +/** + * Creates an encrypted version of the [SqlDriver] using SQLCipher's [SupportFactory]. + * @param passphraseProvider Provides the passphrase needed to use the SQLite database with SQLCipher. + */ +class SqlCipherDriverFactory( + private val passphraseProvider: PassphraseProvider, +) { + /** + * Returns a valid [SqlDriver] with SQLCipher support. + * @param schema The SQLite DB schema. + * @param name The name of the database to create. + * @param context Android [Context], used to instantiate the driver. + */ + fun create(schema: SqlDriver.Schema, name: String, context: Context): SqlDriver { + val passphrase = passphraseProvider.getPassphrase() + val factory = SupportFactory(passphrase) + return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory) + } +} diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/Session.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt similarity index 68% rename from libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/Session.kt rename to libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt index a1943f273c..a12c5f9cfb 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/Session.kt +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt @@ -14,9 +14,14 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.session +package io.element.encrypteddb.passphrase -import io.element.android.libraries.matrix.core.SessionId -import org.matrix.rustcomponents.sdk.Session - -fun Session.sessionId() = SessionId("${userId}_${deviceId}") +/** + * An abstraction to implement secure providers for SQLCipher passphrases. + */ +interface PassphraseProvider { + /** + * Returns a passphrase for SQLCipher in [ByteArray] format. + */ + fun getPassphrase(): ByteArray +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt new file mode 100644 index 0000000000..3367c777d0 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.element.encrypteddb.passphrase + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import java.io.File +import java.security.SecureRandom + +/** + * Provides a secure passphrase for SQLCipher by generating a random secret and storing it into an [EncryptedFile]. + * @param context Android [Context], used by [EncryptedFile] for cryptographic operations. + * @param file Destination file where the key will be stored. + * @param alias Alias of the key used to encrypt & decrypt the [EncryptedFile]'s contents. + * @param secretSize Length of the generated secret. + */ +class RandomSecretPassphraseProvider( + private val context: Context, + private val file: File, + private val alias: String, + private val secretSize: Int = 256, +) : PassphraseProvider { + + override fun getPassphrase(): ByteArray { + val encryptedFile = EncryptedFile.Builder( + file, + context, + alias, + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + return if (!file.exists()) { + val secret = generateSecret() + encryptedFile.openFileOutput().use { it.write(secret) } + secret + } else { + encryptedFile.openFileInput().use { it.readBytes() } + } + } + + private fun generateSecret(): ByteArray { + val buffer = ByteArray(size = secretSize) + SecureRandom().nextBytes(buffer) + return buffer + } +} diff --git a/libraries/matrix/build.gradle.kts b/libraries/matrix/build.gradle.kts index ac1e4f0aa1..42876cbbb1 100644 --- a/libraries/matrix/build.gradle.kts +++ b/libraries/matrix/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.dagger) implementation(projects.libraries.core) implementation("net.java.dev.jna:jna:5.13.0@aar") - implementation(libs.androidx.datastore.preferences) implementation(libs.serialization.json) + api(projects.libraries.sessionStorage) + implementation(libs.coroutines.core) } diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/MatrixClient.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/MatrixClient.kt index 1d3fc62ff7..0e31e307e5 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/MatrixClient.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/MatrixClient.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.matrix import io.element.android.libraries.matrix.core.RoomId import io.element.android.libraries.matrix.core.SessionId -import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrix.media.MediaResolver import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.RoomSummaryDataSource @@ -33,7 +32,6 @@ interface MatrixClient : Closeable { fun roomSummaryDataSource(): RoomSummaryDataSource fun mediaResolver(): MediaResolver suspend fun logout() - fun userId(): UserId suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result suspend fun loadMediaContentForSource(source: MediaSource): Result diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/RustMatrixClient.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/RustMatrixClient.kt index 4d12f104a0..c36aec7107 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/RustMatrixClient.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/RustMatrixClient.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.matrix import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.core.RoomId -import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrix.media.MediaResolver import io.element.android.libraries.matrix.media.RustMediaResolver @@ -26,9 +25,8 @@ import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.RoomSummaryDataSource import io.element.android.libraries.matrix.room.RustMatrixRoom import io.element.android.libraries.matrix.room.RustRoomSummaryDataSource -import io.element.android.libraries.matrix.session.SessionStore -import io.element.android.libraries.matrix.session.sessionId import io.element.android.libraries.matrix.sync.SlidingSyncObserverProxy +import io.element.android.libraries.sessionstorage.SessionStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client @@ -51,7 +49,7 @@ class RustMatrixClient constructor( private val baseDirectory: File, ) : MatrixClient { - override val sessionId: SessionId = client.session().sessionId() + override val sessionId: UserId = UserId(client.userId()) private val clientDelegate = object : ClientDelegate { override fun didReceiveAuthError(isSoftLogout: Boolean) { @@ -174,11 +172,9 @@ class RustMatrixClient constructor( Timber.e(failure, "Fail to call logout on HS. Still delete local files.") } baseDirectory.deleteSessionDirectory(userID = client.userId()) - sessionStore.reset() + sessionStore.removeSession(client.userId()) } - override fun userId(): UserId = UserId(client.userId()) - override suspend fun loadUserDisplayName(): Result = withContext(dispatchers.io) { runCatching { client.displayName() diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/auth/RustMatrixAuthenticationService.kt index 82bd9391b1..1354cf8571 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/auth/RustMatrixAuthenticationService.kt @@ -22,8 +22,9 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.MatrixClient import io.element.android.libraries.matrix.RustMatrixClient import io.element.android.libraries.matrix.core.SessionId -import io.element.android.libraries.matrix.session.SessionStore -import io.element.android.libraries.matrix.session.sessionId +import io.element.android.libraries.matrix.core.UserId +import io.element.android.libraries.matrix.session.SessionData +import io.element.android.libraries.sessionstorage.SessionStore import io.element.android.libraries.matrix.util.logError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -31,6 +32,7 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.AuthenticationService import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.Session import timber.log.Timber import java.io.File import javax.inject.Inject @@ -49,18 +51,18 @@ class RustMatrixAuthenticationService @Inject constructor( } override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { - sessionStore.getLatestSession()?.sessionId() + sessionStore.getLatestSession()?.userId?.let { UserId(it) } } override suspend fun restoreSession(sessionId: SessionId) = withContext(coroutineDispatchers.io) { - sessionStore.getSession(sessionId) - ?.let { session -> + sessionStore.getSession(sessionId.value) + ?.let { sessionData -> try { ClientBuilder() .basePath(baseDirectory.absolutePath) - .username(session.userId) + .username(sessionData.userId) .build().apply { - restoreSession(session) + restoreSession(sessionData.toSession()) } } catch (throwable: Throwable) { logError(throwable) @@ -90,8 +92,8 @@ class RustMatrixAuthenticationService @Inject constructor( throw failure } val session = client.session() - sessionStore.storeData(session) - session.sessionId() + sessionStore.storeData(session.toSessionData()) + SessionId(session.userId) } private fun createMatrixClient(client: Client): MatrixClient { @@ -104,3 +106,23 @@ class RustMatrixAuthenticationService @Inject constructor( ) } } + +private fun SessionData.toSession() = Session( + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + deviceId = deviceId, + homeserverUrl = homeserverUrl, + isSoftLogout = isSoftLogout, + slidingSyncProxy = slidingSyncProxy, +) + +private fun Session.toSessionData() = SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + isSoftLogout = isSoftLogout, + slidingSyncProxy = slidingSyncProxy, +) diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/core/SessionId.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/core/SessionId.kt index bf1ce0c04b..5927354f88 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/core/SessionId.kt +++ b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/core/SessionId.kt @@ -16,7 +16,4 @@ package io.element.android.libraries.matrix.core -import java.io.Serializable - -@JvmInline -value class SessionId(val value: String) : Serializable +typealias SessionId = UserId diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/PreferencesSessionStore.kt b/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/PreferencesSessionStore.kt deleted file mode 100644 index 9468937266..0000000000 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/PreferencesSessionStore.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * 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.element.android.libraries.matrix.session - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.matrix.core.SessionId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.matrix.rustcomponents.sdk.Session -import javax.inject.Inject - -private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_sessions") - -// TODO It contains the access token, so it has to be stored in a more secured storage. -private val sessionKey = stringPreferencesKey("session") - -@SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) -class PreferencesSessionStore @Inject constructor( - @ApplicationContext context: Context -) : SessionStore { - @Serializable - data class SessionData( - val accessToken: String, - val deviceId: String, - val homeserverUrl: String, - val isSoftLogout: Boolean, - val refreshToken: String?, - val userId: String, - val slidingSyncProxy: String? - ) - - private val store = context.dataStore - - override fun isLoggedIn(): Flow { - return store.data.map { prefs -> - prefs[sessionKey] != null - } - } - - override suspend fun storeData(session: Session) { - store.edit { prefs -> - val sessionData = SessionData( - accessToken = session.accessToken, - deviceId = session.deviceId, - homeserverUrl = session.homeserverUrl, - isSoftLogout = session.isSoftLogout, - refreshToken = session.refreshToken, - userId = session.userId, - slidingSyncProxy = session.slidingSyncProxy - ) - val encodedSession = Json.encodeToString(sessionData) - prefs[sessionKey] = encodedSession - } - } - - override suspend fun getLatestSession(): Session? { - return store.data.firstOrNull()?.let { prefs -> - val encodedSession = prefs[sessionKey] ?: return@let null - val sessionData = Json.decodeFromString(encodedSession) - Session( - accessToken = sessionData.accessToken, - deviceId = sessionData.deviceId, - homeserverUrl = sessionData.homeserverUrl, - isSoftLogout = sessionData.isSoftLogout, - refreshToken = sessionData.refreshToken, - userId = sessionData.userId, - slidingSyncProxy = sessionData.slidingSyncProxy - ) - } - } - - override suspend fun getSession(sessionId: SessionId): Session? { - //TODO we should have a proper session management - return getLatestSession() - } - - override suspend fun reset() { - store.edit { it.clear() } - } -} diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt index aedc0fd1a2..7c211c102f 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.matrixtest import io.element.android.libraries.matrix.MatrixClient import io.element.android.libraries.matrix.core.RoomId import io.element.android.libraries.matrix.core.SessionId -import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrix.media.MediaResolver import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.RoomSummaryDataSource @@ -30,7 +29,7 @@ import kotlinx.coroutines.delay import org.matrix.rustcomponents.sdk.MediaSource class FakeMatrixClient( - override val sessionId: SessionId = SessionId(A_SESSION_ID), + override val sessionId: SessionId = A_SESSION_ID, private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() @@ -63,8 +62,6 @@ class FakeMatrixClient( logoutFailure?.let { throw it } } - override fun userId(): UserId = A_USER_ID - override suspend fun loadUserDisplayName(): Result { return userDisplayName } diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt index 970a3882ab..3aa3c2aba3 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt @@ -18,12 +18,14 @@ package io.element.android.libraries.matrixtest import io.element.android.libraries.matrix.core.EventId import io.element.android.libraries.matrix.core.RoomId +import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrix.core.UserId const val A_USER_NAME = "alice" const val A_PASSWORD = "password" val A_USER_ID = UserId("@alice:server.org") +val A_SESSION_ID = SessionId(A_USER_ID.value) val A_ROOM_ID = RoomId("!aRoomId") val AN_EVENT_ID = EventId("\$anEventId") @@ -34,7 +36,6 @@ const val ANOTHER_MESSAGE = "Hello universe!" const val A_HOMESERVER = "matrix.org" const val A_HOMESERVER_2 = "matrix-client.org" -const val A_SESSION_ID = "sessionId" const val AN_AVATAR_URL = "mxc://data" diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt index ab4935ede3..b5981aa2f1 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt @@ -18,9 +18,9 @@ package io.element.android.libraries.matrixtest.auth import io.element.android.libraries.matrix.MatrixClient import io.element.android.libraries.matrix.auth.MatrixAuthenticationService -import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrixtest.A_HOMESERVER -import io.element.android.libraries.matrixtest.A_SESSION_ID +import io.element.android.libraries.matrixtest.A_USER_ID import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -33,11 +33,11 @@ class FakeAuthenticationService : MatrixAuthenticationService { return flowOf(false) } - override suspend fun getLatestSessionId(): SessionId? { + override suspend fun getLatestSessionId(): UserId? { return null } - override suspend fun restoreSession(sessionId: SessionId): MatrixClient? { + override suspend fun restoreSession(userId: UserId): MatrixClient? { return null } @@ -57,10 +57,10 @@ class FakeAuthenticationService : MatrixAuthenticationService { delay(100) } - override suspend fun login(username: String, password: String): SessionId { + override suspend fun login(username: String, password: String): UserId { delay(100) loginError?.let { throw it } - return SessionId(A_SESSION_ID) + return A_USER_ID } fun givenLoginError(throwable: Throwable?) { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/MatrixItemHelper.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/MatrixItemHelper.kt index f60f95cee8..5eae3b2f38 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/MatrixItemHelper.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/MatrixItemHelper.kt @@ -38,13 +38,13 @@ class MatrixItemHelper @Inject constructor( val userDisplayName = client.loadUserDisplayName().getOrNull() val avatarData = AvatarData( - client.userId().value, + client.sessionId.value, userDisplayName, userAvatarUrl, avatarSize ) MatrixUser( - id = client.userId(), + id = client.sessionId, username = userDisplayName, avatarData = avatarData, ) diff --git a/libraries/session-storage/build.gradle.kts b/libraries/session-storage/build.gradle.kts new file mode 100644 index 0000000000..2fd74a4670 --- /dev/null +++ b/libraries/session-storage/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.sqldelight) +} + +android { + namespace = "io.element.android.libraries.sessionstorage" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation(projects.libraries.encryptedDb) + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.androidx.security.crypto) + implementation(projects.libraries.di) + implementation(libs.sqldelight.coroutines) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.test) + testImplementation(libs.sqldelight.driver.jvm) +} + +sqldelight { + database("SessionDatabase") {} +} diff --git a/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStore.kt b/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStore.kt new file mode 100644 index 0000000000..d833f06d92 --- /dev/null +++ b/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStore.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.element.android.libraries.sessionstorage + +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.session.SessionData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DatabaseSessionStore @Inject constructor( + private val database: SessionDatabase, +) : SessionStore { + + override fun isLoggedIn(): Flow { + return database.sessionDataQueries.selectFirst().asFlow().mapToOneOrNull().map { it != null } + } + + override suspend fun storeData(sessionData: SessionData) { + database.sessionDataQueries.insertSessionData(sessionData) + } + + override suspend fun getLatestSession(): SessionData? { + return database.sessionDataQueries.selectFirst() + .executeAsOneOrNull() + } + + override suspend fun getSession(sessionId: String): SessionData? { + return database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + } + + override suspend fun removeSession(sessionId: String) { + database.sessionDataQueries.removeSession(sessionId) + } +} diff --git a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/SessionStore.kt b/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/SessionStore.kt similarity index 68% rename from libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/SessionStore.kt rename to libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/SessionStore.kt index 3ca806acee..a8cd52df70 100644 --- a/libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/SessionStore.kt +++ b/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/SessionStore.kt @@ -14,16 +14,15 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.session +package io.element.android.libraries.sessionstorage -import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrix.session.SessionData import kotlinx.coroutines.flow.Flow -import org.matrix.rustcomponents.sdk.Session interface SessionStore { fun isLoggedIn(): Flow - suspend fun storeData(session: Session) - suspend fun getSession(sessionId: SessionId): Session? - suspend fun getLatestSession(): Session? - suspend fun reset() + suspend fun storeData(session: SessionData) + suspend fun getSession(sessionId: String): SessionData? + suspend fun getLatestSession(): SessionData? + suspend fun removeSession(sessionId: String) } diff --git a/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/di/SessionStorageModule.kt b/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/di/SessionStorageModule.kt new file mode 100644 index 0000000000..33cdc50257 --- /dev/null +++ b/libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/di/SessionStorageModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.element.android.libraries.sessionstorage.di + +import android.content.Context +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.SessionDatabase +import io.element.encrypteddb.SqlCipherDriverFactory +import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider + +@Module +@ContributesTo(AppScope::class) +object SessionStorageModule { + @Provides + @SingleIn(AppScope::class) + fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase { + val name = "session_database" + val secretFile = context.getDatabasePath("$name.key") + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile, name) + val driver = SqlCipherDriverFactory(passphraseProvider) + .create(SessionDatabase.Schema, "$name.db", context) + return SessionDatabase(driver) + } +} diff --git a/libraries/session-storage/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq new file mode 100644 index 0000000000..c0731361e2 --- /dev/null +++ b/libraries/session-storage/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -0,0 +1,21 @@ +CREATE TABLE SessionData ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + isSoftLogout INTEGER AS Boolean NOT NULL DEFAULT 0, + slidingSyncProxy TEXT +); + +selectFirst: +SELECT * FROM SessionData LIMIT 1; + +selectByUserId: +SELECT * FROM SessionData WHERE userId = ?; + +insertSessionData: +INSERT INTO SessionData(userId, deviceId, accessToken, refreshToken, homeserverUrl, isSoftLogout, slidingSyncProxy) VALUES ?; + +removeSession: +DELETE FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/src/test/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStoreTests.kt b/libraries/session-storage/src/test/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStoreTests.kt new file mode 100644 index 0000000000..56c5e4db16 --- /dev/null +++ b/libraries/session-storage/src/test/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStoreTests.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.element.android.libraries.sessionstorage + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver +import io.element.android.libraries.matrix.session.SessionData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DatabaseSessionStoreTests { + + private lateinit var database: SessionDatabase + private lateinit var databaseSessionStore: DatabaseSessionStore + + private val aSessionData = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + isSoftLogout = false, + slidingSyncProxy = null + ) + + @Before + fun setup() { + // Initialise in memory SQLite driver + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + SessionDatabase.Schema.create(driver) + + database = SessionDatabase(driver) + databaseSessionStore = DatabaseSessionStore(database) + } + + @Test + fun `storeData persists the SessionData into the DB`() = runTest { + assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull() + + databaseSessionStore.storeData(aSessionData) + + assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData) + } + + @Test + fun `isLoggedIn emits true while there are sessions in the DB`() = runTest { + databaseSessionStore.isLoggedIn().test { + assertThat(awaitItem()).isFalse() + database.sessionDataQueries.insertSessionData(aSessionData) + assertThat(awaitItem()).isTrue() + database.sessionDataQueries.removeSession(aSessionData.userId) + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `getLatestSession gets the first session in the DB`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId")) + + val latestSession = databaseSessionStore.getLatestSession() + + assertThat(latestSession).isEqualTo(aSessionData) + } + + @Test + fun `getSession returns a matching session in DB if exists`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId")) + + val foundSession = databaseSessionStore.getSession(aSessionData.userId) + + assertThat(foundSession).isEqualTo(aSessionData) + } + + @Test + fun `getSession returns null if a no matching session exists in DB`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId")) + + val foundSession = databaseSessionStore.getSession(aSessionData.userId) + + assertThat(foundSession).isNull() + } + + @Test + fun `removeSession removes the associated session in DB`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + + databaseSessionStore.removeSession(aSessionData.userId) + + assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() + } + +} diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 217a3318fa..6236bfd307 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -23,7 +23,7 @@ object Versions { const val compileSdk = 33 const val targetSdk = 33 - const val minSdk = 21 + const val minSdk = 23 val javaCompileVersion = JavaVersion.VERSION_11 val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) } diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 835c3fc772..05d8ed17a2 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -54,5 +54,6 @@ dependencies { implementation(projects.libraries.dateformatter) implementation(projects.features.roomlist) implementation(projects.features.login) + implementation(libs.coroutines.core) coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.2") } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/InMemorySessionStore.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/InMemorySessionStore.kt new file mode 100644 index 0000000000..f96ea63d72 --- /dev/null +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/InMemorySessionStore.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.element.android.samples.minimal + +import io.element.android.libraries.matrix.session.SessionData +import io.element.android.libraries.sessionstorage.SessionStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class InMemorySessionStore : SessionStore { + + private var sessionData = MutableStateFlow(null) + + override fun isLoggedIn(): Flow { + return sessionData.map { it != null } + } + + override suspend fun storeData(session: SessionData) { + sessionData.value = session + } + + override suspend fun getSession(sessionId: String): SessionData? { + return sessionData.value.takeIf { it?.userId == sessionId } + } + + override suspend fun getLatestSession(): SessionData? { + return sessionData.value + } + + override suspend fun removeSession(sessionId: String) { + if (sessionData.value?.userId == sessionId) { + sessionData.value = null + } + } +} diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index 1b6c79f518..bc7131de01 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -31,7 +31,6 @@ import androidx.core.view.WindowCompat import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.auth.RustMatrixAuthenticationService -import io.element.android.libraries.matrix.session.PreferencesSessionStore import kotlinx.coroutines.runBlocking import org.matrix.rustcomponents.sdk.AuthenticationService import java.io.File @@ -45,7 +44,7 @@ class MainActivity : ComponentActivity() { baseDirectory = baseDirectory, coroutineScope = Singleton.appScope, coroutineDispatchers = Singleton.coroutineDispatchers, - sessionStore = PreferencesSessionStore(applicationContext), + sessionStore = InMemorySessionStore(), authService = AuthenticationService(baseDirectory.absolutePath, null, null), ) } diff --git a/samples/minimal/src/main/res/values-night/themes.xml b/samples/minimal/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..b059f36a0e --- /dev/null +++ b/samples/minimal/src/main/res/values-night/themes.xml @@ -0,0 +1,20 @@ + + + + +