Browse Source

Store session data in a secure way (#98)

* Replace SessionData DataStore with an encrypted SQLite DB.

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
misc/jme/add-logging-to-state-machine
Jorge Martin Espinosa 2 years ago committed by GitHub
parent
commit
6677f80abe
  1. 2
      README.md
  2. 11
      app/src/main/kotlin/io/element/android/x/di/MatrixClientsHolder.kt
  3. 15
      app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt
  4. 1
      changelog.d/84.feature
  5. 4
      features/login/src/main/kotlin/io/element/android/features/login/root/LoginRootScreen.kt
  6. 3
      features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt
  7. 9
      features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt
  8. 7
      features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt
  9. 18
      features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt
  10. 8
      gradle/libs.versions.toml
  11. 6
      libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt
  12. 30
      libraries/encrypted-db/build.gradle.kts
  13. 43
      libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt
  14. 15
      libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt
  15. 59
      libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt
  16. 3
      libraries/matrix/build.gradle.kts
  17. 2
      libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/MatrixClient.kt
  18. 10
      libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/RustMatrixClient.kt
  19. 40
      libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/auth/RustMatrixAuthenticationService.kt
  20. 5
      libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/core/SessionId.kt
  21. 109
      libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/PreferencesSessionStore.kt
  22. 5
      libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt
  23. 3
      libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt
  24. 12
      libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt
  25. 4
      libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/MatrixItemHelper.kt
  26. 51
      libraries/session-storage/build.gradle.kts
  27. 56
      libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStore.kt
  28. 13
      libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/SessionStore.kt
  29. 43
      libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/di/SessionStorageModule.kt
  30. 21
      libraries/session-storage/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq
  31. 112
      libraries/session-storage/src/test/kotlin/io/element/android/libraries/sessionstorage/DatabaseSessionStoreTests.kt
  32. 2
      plugins/src/main/kotlin/Versions.kt
  33. 1
      samples/minimal/build.gradle.kts
  34. 50
      samples/minimal/src/main/kotlin/io/element/android/samples/minimal/InMemorySessionStore.kt
  35. 3
      samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt
  36. 20
      samples/minimal/src/main/res/values-night/themes.xml
  37. 1
      samples/minimal/src/main/res/values/themes.xml
  38. 2
      settings.gradle.kts

2
README.md

@ -10,7 +10,7 @@
ElementX Android is a [Matrix](https://matrix.org/) Android Client provided by [Element](https://element.io/). 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.
<!--- TOC --> <!--- TOC -->

11
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.MatrixClient
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrix.core.SessionId
import io.element.android.libraries.matrix.core.UserId
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -57,13 +58,13 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
fun restore(savedInstanceState: Bundle?) { fun restore(savedInstanceState: Bundle?) {
if (savedInstanceState == null || sessionIdsToMatrixClient.isNotEmpty()) return if (savedInstanceState == null || sessionIdsToMatrixClient.isNotEmpty()) return
val sessionIds = savedInstanceState.getSerializable(SAVE_INSTANCE_KEY) as? Array<SessionId> val userIds = savedInstanceState.getSerializable(SAVE_INSTANCE_KEY) as? Array<UserId>
if (sessionIds.isNullOrEmpty()) return 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. // 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 { runBlocking {
sessionIds.forEach { sessionId -> userIds.forEach { userId ->
Timber.v("Restore matrix session: $sessionId") Timber.v("Restore matrix session: $userId")
val matrixClient = authenticationService.restoreSession(sessionId) val matrixClient = authenticationService.restoreSession(userId)
if (matrixClient != null) { if (matrixClient != null) {
add(matrixClient) add(matrixClient)
} }

15
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.di.DaggerComponentOwner
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.core.SessionId 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.di.MatrixClientsHolder
import io.element.android.x.root.RootPresenter import io.element.android.x.root.RootPresenter
import io.element.android.x.root.RootView import io.element.android.x.root.RootView
@ -89,7 +90,7 @@ class RootFlowNode(
} }
private fun switchToLoggedInFlow(sessionId: SessionId) { private fun switchToLoggedInFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId = sessionId)) backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
} }
private fun switchToLogoutFlow() { private fun switchToLogoutFlow() {
@ -98,19 +99,19 @@ class RootFlowNode(
} }
private suspend fun tryToRestoreLatestSession( private suspend fun tryToRestoreLatestSession(
onSuccess: (SessionId) -> Unit = {}, onSuccess: (UserId) -> Unit = {},
onFailure: () -> Unit = {} onFailure: () -> Unit = {}
) { ) {
val latestKnownSessionId = authenticationService.getLatestSessionId() val latestKnownUserId = authenticationService.getLatestSessionId()
if (latestKnownSessionId == null) { if (latestKnownUserId == null) {
onFailure() onFailure()
return return
} }
if (matrixClientsHolder.knowSession(latestKnownSessionId)) { if (matrixClientsHolder.knowSession(latestKnownUserId)) {
onSuccess(latestKnownSessionId) onSuccess(latestKnownUserId)
return return
} }
val matrixClient = authenticationService.restoreSession(latestKnownSessionId) val matrixClient = authenticationService.restoreSession(UserId(latestKnownUserId.value))
if (matrixClient == null) { if (matrixClient == null) {
Timber.v("Failed to restore session...") Timber.v("Failed to restore session...")
onFailure() onFailure()

1
changelog.d/84.feature

@ -0,0 +1 @@
Store session data in a secure storage.

4
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.IconButton
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text 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.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@ -68,7 +68,7 @@ fun LoginRootScreen(
state: LoginRootState, state: LoginRootState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {}, onChangeServer: () -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {}, onLoginWithSuccess: (UserId) -> Unit = {},
) { ) {
val eventSink = state.eventSink val eventSink = state.eventSink
Box( Box(

3
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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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
import io.element.android.libraries.matrixtest.A_HOMESERVER_2 import io.element.android.libraries.matrixtest.A_HOMESERVER_2
import io.element.android.libraries.matrixtest.A_PASSWORD import io.element.android.libraries.matrixtest.A_PASSWORD
@ -88,7 +87,7 @@ class LoginRootPresenterTest {
val submitState = awaitItem() val submitState = awaitItem()
assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
val loggedInState = awaitItem() val loggedInState = awaitItem()
assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(SessionId(A_SESSION_ID))) assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
} }
} }

9
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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async 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_THROWABLE
import io.element.android.libraries.matrixtest.A_USER_ID
import io.element.android.libraries.matrixtest.FakeMatrixClient import io.element.android.libraries.matrixtest.FakeMatrixClient
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -34,7 +35,7 @@ class LogoutPreferencePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = LogoutPreferencePresenter( val presenter = LogoutPreferencePresenter(
FakeMatrixClient(SessionId("sessionId")), FakeMatrixClient(A_SESSION_ID),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -47,7 +48,7 @@ class LogoutPreferencePresenterTest {
@Test @Test
fun `present - logout`() = runTest { fun `present - logout`() = runTest {
val presenter = LogoutPreferencePresenter( val presenter = LogoutPreferencePresenter(
FakeMatrixClient(SessionId("sessionId")), FakeMatrixClient(A_SESSION_ID),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -63,7 +64,7 @@ class LogoutPreferencePresenterTest {
@Test @Test
fun `present - logout with error`() = runTest { fun `present - logout with error`() = runTest {
val matrixClient = FakeMatrixClient(SessionId("sessionId")) val matrixClient = FakeMatrixClient(A_SESSION_ID)
val presenter = LogoutPreferencePresenter( val presenter = LogoutPreferencePresenter(
matrixClient, matrixClient,
) )

7
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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.MatrixClient 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.room.RoomSummary
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -107,14 +108,14 @@ class RoomListPresenter @Inject constructor(
val userDisplayName = client.loadUserDisplayName().getOrNull() val userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData = val avatarData =
AvatarData( AvatarData(
id = client.userId().value, id = client.sessionId.value,
name = userDisplayName, name = userDisplayName,
url = userAvatarUrl, url = userAvatarUrl,
size = AvatarSize.SMALL size = AvatarSize.SMALL
) )
matrixUser.value = MatrixUser( matrixUser.value = MatrixUser(
id = client.userId(), id = UserId(client.sessionId.value),
username = userDisplayName ?: client.userId().value, username = userDisplayName ?: client.sessionId.value,
avatarData = avatarData, avatarData = avatarData,
) )
} }

18
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.features.roomlist.model.RoomListRoomSummary
import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.dateformatter.LastMessageFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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_AVATAR_URL
import io.element.android.libraries.matrixtest.AN_EXCEPTION import io.element.android.libraries.matrixtest.AN_EXCEPTION
import io.element.android.libraries.matrixtest.A_MESSAGE 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_ID
import io.element.android.libraries.matrixtest.A_ROOM_NAME 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_ID
import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.A_USER_NAME
import io.element.android.libraries.matrixtest.FakeMatrixClient import io.element.android.libraries.matrixtest.FakeMatrixClient
@ -46,9 +46,7 @@ class RoomListPresenterTests {
@Test @Test
fun `present - should start with no user and then load user with success`() = runTest { fun `present - should start with no user and then load user with success`() = runTest {
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(A_SESSION_ID),
SessionId("sessionId")
),
createDateFormatter() createDateFormatter()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -69,7 +67,7 @@ class RoomListPresenterTests {
fun `present - should start with no user and then load user with error`() = runTest { fun `present - should start with no user and then load user with error`() = runTest {
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(
SessionId("sessionId"), A_SESSION_ID,
userDisplayName = Result.failure(AN_EXCEPTION), userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION), userAvatarURLString = Result.failure(AN_EXCEPTION),
), ),
@ -90,9 +88,7 @@ class RoomListPresenterTests {
@Test @Test
fun `present - should filter room with success`() = runTest { fun `present - should filter room with success`() = runTest {
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(A_SESSION_ID),
SessionId("sessionId")
),
createDateFormatter() createDateFormatter()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -112,7 +108,7 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource() val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = SessionId("sessionId"), sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource roomSummaryDataSource = roomSummaryDataSource
), ),
createDateFormatter() createDateFormatter()
@ -138,7 +134,7 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource() val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = SessionId("sessionId"), sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource roomSummaryDataSource = roomSummaryDataSource
), ),
createDateFormatter() createDateFormatter()
@ -169,7 +165,7 @@ class RoomListPresenterTests {
val roomSummaryDataSource = FakeRoomSummaryDataSource() val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = SessionId("sessionId"), sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource roomSummaryDataSource = roomSummaryDataSource
), ),
createDateFormatter() createDateFormatter()

8
gradle/libs.versions.toml

@ -42,6 +42,7 @@ jsoup = "1.15.3"
appyx = "1.0.3" appyx = "1.0.3"
dependencycheck = "7.4.4" dependencycheck = "7.4.4"
stem = "2.2.3" stem = "2.2.3"
sqldelight = "1.5.5"
# DI # DI
dagger = "2.44.2" 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_compose = { module = "androidx.lifecycle:compose", version.ref = "lifecycle" }
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx_splash = "androidx.core:core-splashscreen:1.0.0" 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_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" }
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" } 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" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.2" 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 # Di
inject = "javax.inject:javax.inject:1" 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" paparazzi = "app.cash.paparazzi:1.2.0"
sonarqube = "org.sonarqube:3.5.0.2730" sonarqube = "org.sonarqube:3.5.0.2730"
kover = "org.jetbrains.kotlinx.kover:0.6.1" kover = "org.jetbrains.kotlinx.kover:0.6.1"
sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }

6
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 { private val onlyTimeFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm" val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm"
DateTimeFormatter.ofPattern(pattern) DateTimeFormatter.ofPattern(pattern, locale)
} }
private val dateWithMonthFormatter: DateTimeFormatter by lazy { private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM" val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM"
DateTimeFormatter.ofPattern(pattern) DateTimeFormatter.ofPattern(pattern, locale)
} }
private val dateWithYearFormatter: DateTimeFormatter by lazy { private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy" val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy"
DateTimeFormatter.ofPattern(pattern) DateTimeFormatter.ofPattern(pattern, locale)
} }
internal fun formatTime(localDateTime: LocalDateTime): String { internal fun formatTime(localDateTime: LocalDateTime): String {

30
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)
}

43
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)
}
}

15
libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/Session.kt → libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt

@ -14,9 +14,14 @@
* limitations under the License. * 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 * An abstraction to implement secure providers for SQLCipher passphrases.
*/
fun Session.sessionId() = SessionId("${userId}_${deviceId}") interface PassphraseProvider {
/**
* Returns a passphrase for SQLCipher in [ByteArray] format.
*/
fun getPassphrase(): ByteArray
}

59
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
}
}

3
libraries/matrix/build.gradle.kts

@ -37,6 +37,7 @@ dependencies {
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation("net.java.dev.jna:jna:5.13.0@aar") implementation("net.java.dev.jna:jna:5.13.0@aar")
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json) implementation(libs.serialization.json)
api(projects.libraries.sessionStorage)
implementation(libs.coroutines.core)
} }

2
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.RoomId
import io.element.android.libraries.matrix.core.SessionId 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.MediaResolver
import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.MatrixRoom
import io.element.android.libraries.matrix.room.RoomSummaryDataSource import io.element.android.libraries.matrix.room.RoomSummaryDataSource
@ -33,7 +32,6 @@ interface MatrixClient : Closeable {
fun roomSummaryDataSource(): RoomSummaryDataSource fun roomSummaryDataSource(): RoomSummaryDataSource
fun mediaResolver(): MediaResolver fun mediaResolver(): MediaResolver
suspend fun logout() suspend fun logout()
fun userId(): UserId
suspend fun loadUserDisplayName(): Result<String> suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String> suspend fun loadUserAvatarURLString(): Result<String>
suspend fun loadMediaContentForSource(source: MediaSource): Result<ByteArray> suspend fun loadMediaContentForSource(source: MediaSource): Result<ByteArray>

10
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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.media.MediaResolver import io.element.android.libraries.matrix.media.MediaResolver
import io.element.android.libraries.matrix.media.RustMediaResolver 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.RoomSummaryDataSource
import io.element.android.libraries.matrix.room.RustMatrixRoom import io.element.android.libraries.matrix.room.RustMatrixRoom
import io.element.android.libraries.matrix.room.RustRoomSummaryDataSource 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.matrix.sync.SlidingSyncObserverProxy
import io.element.android.libraries.sessionstorage.SessionStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
@ -51,7 +49,7 @@ class RustMatrixClient constructor(
private val baseDirectory: File, private val baseDirectory: File,
) : MatrixClient { ) : MatrixClient {
override val sessionId: SessionId = client.session().sessionId() override val sessionId: UserId = UserId(client.userId())
private val clientDelegate = object : ClientDelegate { private val clientDelegate = object : ClientDelegate {
override fun didReceiveAuthError(isSoftLogout: Boolean) { 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.") Timber.e(failure, "Fail to call logout on HS. Still delete local files.")
} }
baseDirectory.deleteSessionDirectory(userID = client.userId()) baseDirectory.deleteSessionDirectory(userID = client.userId())
sessionStore.reset() sessionStore.removeSession(client.userId())
} }
override fun userId(): UserId = UserId(client.userId())
override suspend fun loadUserDisplayName(): Result<String> = withContext(dispatchers.io) { override suspend fun loadUserDisplayName(): Result<String> = withContext(dispatchers.io) {
runCatching { runCatching {
client.displayName() client.displayName()

40
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.MatrixClient
import io.element.android.libraries.matrix.RustMatrixClient import io.element.android.libraries.matrix.RustMatrixClient
import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrix.core.SessionId
import io.element.android.libraries.matrix.session.SessionStore import io.element.android.libraries.matrix.core.UserId
import io.element.android.libraries.matrix.session.sessionId import io.element.android.libraries.matrix.session.SessionData
import io.element.android.libraries.sessionstorage.SessionStore
import io.element.android.libraries.matrix.util.logError import io.element.android.libraries.matrix.util.logError
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -31,6 +32,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.AuthenticationService import org.matrix.rustcomponents.sdk.AuthenticationService
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.Session
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -49,18 +51,18 @@ class RustMatrixAuthenticationService @Inject constructor(
} }
override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { 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) { override suspend fun restoreSession(sessionId: SessionId) = withContext(coroutineDispatchers.io) {
sessionStore.getSession(sessionId) sessionStore.getSession(sessionId.value)
?.let { session -> ?.let { sessionData ->
try { try {
ClientBuilder() ClientBuilder()
.basePath(baseDirectory.absolutePath) .basePath(baseDirectory.absolutePath)
.username(session.userId) .username(sessionData.userId)
.build().apply { .build().apply {
restoreSession(session) restoreSession(sessionData.toSession())
} }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
logError(throwable) logError(throwable)
@ -90,8 +92,8 @@ class RustMatrixAuthenticationService @Inject constructor(
throw failure throw failure
} }
val session = client.session() val session = client.session()
sessionStore.storeData(session) sessionStore.storeData(session.toSessionData())
session.sessionId() SessionId(session.userId)
} }
private fun createMatrixClient(client: Client): MatrixClient { 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,
)

5
libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/core/SessionId.kt

@ -16,7 +16,4 @@
package io.element.android.libraries.matrix.core package io.element.android.libraries.matrix.core
import java.io.Serializable typealias SessionId = UserId
@JvmInline
value class SessionId(val value: String) : Serializable

109
libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/PreferencesSessionStore.kt

@ -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<Preferences> 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<Boolean> {
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<SessionData>(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() }
}
}

5
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.MatrixClient
import io.element.android.libraries.matrix.core.RoomId import io.element.android.libraries.matrix.core.RoomId
import io.element.android.libraries.matrix.core.SessionId 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.MediaResolver
import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.MatrixRoom
import io.element.android.libraries.matrix.room.RoomSummaryDataSource import io.element.android.libraries.matrix.room.RoomSummaryDataSource
@ -30,7 +29,7 @@ import kotlinx.coroutines.delay
import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.MediaSource
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = SessionId(A_SESSION_ID), override val sessionId: SessionId = A_SESSION_ID,
private val userDisplayName: Result<String> = Result.success(A_USER_NAME), private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL), private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource()
@ -63,8 +62,6 @@ class FakeMatrixClient(
logoutFailure?.let { throw it } logoutFailure?.let { throw it }
} }
override fun userId(): UserId = A_USER_ID
override suspend fun loadUserDisplayName(): Result<String> { override suspend fun loadUserDisplayName(): Result<String> {
return userDisplayName return userDisplayName
} }

3
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.EventId
import io.element.android.libraries.matrix.core.RoomId 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.core.UserId
const val A_USER_NAME = "alice" const val A_USER_NAME = "alice"
const val A_PASSWORD = "password" const val A_PASSWORD = "password"
val A_USER_ID = UserId("@alice:server.org") val A_USER_ID = UserId("@alice:server.org")
val A_SESSION_ID = SessionId(A_USER_ID.value)
val A_ROOM_ID = RoomId("!aRoomId") val A_ROOM_ID = RoomId("!aRoomId")
val AN_EVENT_ID = EventId("\$anEventId") 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 = "matrix.org"
const val A_HOMESERVER_2 = "matrix-client.org" const val A_HOMESERVER_2 = "matrix-client.org"
const val A_SESSION_ID = "sessionId"
const val AN_AVATAR_URL = "mxc://data" const val AN_AVATAR_URL = "mxc://data"

12
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.MatrixClient
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService 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_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.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -33,11 +33,11 @@ class FakeAuthenticationService : MatrixAuthenticationService {
return flowOf(false) return flowOf(false)
} }
override suspend fun getLatestSessionId(): SessionId? { override suspend fun getLatestSessionId(): UserId? {
return null return null
} }
override suspend fun restoreSession(sessionId: SessionId): MatrixClient? { override suspend fun restoreSession(userId: UserId): MatrixClient? {
return null return null
} }
@ -57,10 +57,10 @@ class FakeAuthenticationService : MatrixAuthenticationService {
delay(100) delay(100)
} }
override suspend fun login(username: String, password: String): SessionId { override suspend fun login(username: String, password: String): UserId {
delay(100) delay(100)
loginError?.let { throw it } loginError?.let { throw it }
return SessionId(A_SESSION_ID) return A_USER_ID
} }
fun givenLoginError(throwable: Throwable?) { fun givenLoginError(throwable: Throwable?) {

4
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 userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData = val avatarData =
AvatarData( AvatarData(
client.userId().value, client.sessionId.value,
userDisplayName, userDisplayName,
userAvatarUrl, userAvatarUrl,
avatarSize avatarSize
) )
MatrixUser( MatrixUser(
id = client.userId(), id = client.sessionId,
username = userDisplayName, username = userDisplayName,
avatarData = avatarData, avatarData = avatarData,
) )

51
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") {}
}

56
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<Boolean> {
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)
}
}

13
libraries/matrix/src/main/kotlin/io/element/android/libraries/matrix/session/SessionStore.kt → libraries/session-storage/src/main/kotlin/io/element/android/libraries/sessionstorage/SessionStore.kt

@ -14,16 +14,15 @@
* limitations under the License. * 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 kotlinx.coroutines.flow.Flow
import org.matrix.rustcomponents.sdk.Session
interface SessionStore { interface SessionStore {
fun isLoggedIn(): Flow<Boolean> fun isLoggedIn(): Flow<Boolean>
suspend fun storeData(session: Session) suspend fun storeData(session: SessionData)
suspend fun getSession(sessionId: SessionId): Session? suspend fun getSession(sessionId: String): SessionData?
suspend fun getLatestSession(): Session? suspend fun getLatestSession(): SessionData?
suspend fun reset() suspend fun removeSession(sessionId: String)
} }

43
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)
}
}

21
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 = ?;

112
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()
}
}

2
plugins/src/main/kotlin/Versions.kt

@ -23,7 +23,7 @@ object Versions {
const val compileSdk = 33 const val compileSdk = 33
const val targetSdk = 33 const val targetSdk = 33
const val minSdk = 21 const val minSdk = 23
val javaCompileVersion = JavaVersion.VERSION_11 val javaCompileVersion = JavaVersion.VERSION_11
val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11)
} }

1
samples/minimal/build.gradle.kts

@ -54,5 +54,6 @@ dependencies {
implementation(projects.libraries.dateformatter) implementation(projects.libraries.dateformatter)
implementation(projects.features.roomlist) implementation(projects.features.roomlist)
implementation(projects.features.login) implementation(projects.features.login)
implementation(libs.coroutines.core)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.2") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.2")
} }

50
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<SessionData?>(null)
override fun isLoggedIn(): Flow<Boolean> {
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
}
}
}

3
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.designsystem.theme.ElementTheme
import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.auth.RustMatrixAuthenticationService import io.element.android.libraries.matrix.auth.RustMatrixAuthenticationService
import io.element.android.libraries.matrix.session.PreferencesSessionStore
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.matrix.rustcomponents.sdk.AuthenticationService import org.matrix.rustcomponents.sdk.AuthenticationService
import java.io.File import java.io.File
@ -45,7 +44,7 @@ class MainActivity : ComponentActivity() {
baseDirectory = baseDirectory, baseDirectory = baseDirectory,
coroutineScope = Singleton.appScope, coroutineScope = Singleton.appScope,
coroutineDispatchers = Singleton.coroutineDispatchers, coroutineDispatchers = Singleton.coroutineDispatchers,
sessionStore = PreferencesSessionStore(applicationContext), sessionStore = InMemorySessionStore(),
authService = AuthenticationService(baseDirectory.absolutePath, null, null), authService = AuthenticationService(baseDirectory.absolutePath, null, null),
) )
} }

20
samples/minimal/src/main/res/values-night/themes.xml

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<style name="Theme.ElementX" parent="android:Theme.Material.NoActionBar" />
</resources>

1
samples/minimal/src/main/res/values/themes.xml

@ -16,6 +16,5 @@
--> -->
<resources> <resources>
<style name="Theme.ElementX" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.ElementX" parent="android:Theme.Material.Light.NoActionBar" />
</resources> </resources>

2
settings.gradle.kts

@ -63,3 +63,5 @@ include(":libraries:matrixtest")
include(":features:template") include(":features:template")
include(":libraries:androidutils") include(":libraries:androidutils")
include(":samples:minimal") include(":samples:minimal")
include(":libraries:encrypted-db")
include(":libraries:session-storage")

Loading…
Cancel
Save