Store session data in a secure way (#98)
* Replace SessionData DataStore with an encrypted SQLite DB. --------- Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
committed by
GitHub
parent
1ec629c304
commit
c20013243b
@@ -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.
|
||||
|
||||
<!--- TOC -->
|
||||
|
||||
|
||||
@@ -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<SessionId>
|
||||
if (sessionIds.isNullOrEmpty()) return
|
||||
val userIds = savedInstanceState.getSerializable(SAVE_INSTANCE_KEY) as? Array<UserId>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
changelog.d/84.feature
Normal file
1
changelog.d/84.feature
Normal file
@@ -0,0 +1 @@
|
||||
Store session data in a secure storage.
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
libraries/encrypted-db/build.gradle.kts
Normal file
30
libraries/encrypted-db/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<String>
|
||||
suspend fun loadUserAvatarURLString(): Result<String>
|
||||
suspend fun loadMediaContentForSource(source: MediaSource): Result<ByteArray>
|
||||
|
||||
@@ -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<String> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
client.displayName()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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<String> = Result.success(A_USER_NAME),
|
||||
private val userAvatarURLString: Result<String> = 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<String> {
|
||||
return userDisplayName
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
51
libraries/session-storage/build.gradle.kts
Normal file
51
libraries/session-storage/build.gradle.kts
Normal file
@@ -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") {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean>
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ?;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
20
samples/minimal/src/main/res/values-night/themes.xml
Normal file
20
samples/minimal/src/main/res/values-night/themes.xml
Normal file
@@ -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>
|
||||
@@ -16,6 +16,5 @@
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<style name="Theme.ElementX" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -63,3 +63,5 @@ include(":libraries:matrixtest")
|
||||
include(":features:template")
|
||||
include(":libraries:androidutils")
|
||||
include(":samples:minimal")
|
||||
include(":libraries:encrypted-db")
|
||||
include(":libraries:session-storage")
|
||||
|
||||
Reference in New Issue
Block a user