forked from dsutanto/bChot-android
First Commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.sessionstorage.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.api
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed interface LoggedInState {
|
||||
data object NotLoggedIn : LoggedInState
|
||||
data class LoggedIn(
|
||||
val sessionId: String,
|
||||
val isTokenValid: Boolean,
|
||||
) : LoggedInState
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.api
|
||||
|
||||
// Imported from Element Android, to be able to migrate from EA to EXA.
|
||||
enum class LoginType {
|
||||
PASSWORD,
|
||||
OIDC,
|
||||
SSO,
|
||||
UNSUPPORTED,
|
||||
CUSTOM,
|
||||
DIRECT,
|
||||
UNKNOWN,
|
||||
QR;
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String) = when (name) {
|
||||
PASSWORD.name -> PASSWORD
|
||||
OIDC.name -> OIDC
|
||||
SSO.name -> SSO
|
||||
UNSUPPORTED.name -> UNSUPPORTED
|
||||
CUSTOM.name -> CUSTOM
|
||||
DIRECT.name -> DIRECT
|
||||
QR.name -> QR
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.api
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Data class representing the session data to store locally.
|
||||
*/
|
||||
data class SessionData(
|
||||
/** The user ID of the logged in user. */
|
||||
val userId: String,
|
||||
/** The device ID of the session. */
|
||||
val deviceId: String,
|
||||
/** The current access token of the session. */
|
||||
val accessToken: String,
|
||||
/** The optional current refresh token of the session. */
|
||||
val refreshToken: String?,
|
||||
/** The homeserver URL of the session. */
|
||||
val homeserverUrl: String,
|
||||
/** The Open ID Connect info for this session, if any. */
|
||||
val oidcData: String?,
|
||||
/** The timestamp of the last login. May be `null` in very old sessions. */
|
||||
val loginTimestamp: Date?,
|
||||
/** Whether the [accessToken] is valid or not. */
|
||||
val isTokenValid: Boolean,
|
||||
/** The login type used to authenticate the session. */
|
||||
val loginType: LoginType,
|
||||
/** The optional passphrase used to encrypt data in the SDK local store. */
|
||||
val passphrase: String?,
|
||||
/** The paths to the session data stored in the filesystem. */
|
||||
val sessionPath: String,
|
||||
/** The path to the cache data stored for the session in the filesystem. */
|
||||
val cachePath: String,
|
||||
/** The position, to be able to order account. */
|
||||
val position: Long,
|
||||
/** The index of the last date of session usage. */
|
||||
val lastUsageIndex: Long,
|
||||
/** The optional display name of the user. */
|
||||
val userDisplayName: String?,
|
||||
/** The optional avatar URL of the user. */
|
||||
val userAvatarUrl: String?,
|
||||
)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.api
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
interface SessionStore {
|
||||
/**
|
||||
* A flow emitting the current logged in state.
|
||||
* If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session.
|
||||
* If there is no session, the state is [LoggedInState.NotLoggedIn].
|
||||
*/
|
||||
fun loggedInStateFlow(): Flow<LoggedInState>
|
||||
|
||||
/**
|
||||
* Return a flow of all sessions ordered by last usage descending.
|
||||
*/
|
||||
fun sessionsFlow(): Flow<List<SessionData>>
|
||||
|
||||
/**
|
||||
* Add a new session. If other sessions exist, the new one will be set as the latest used one, and
|
||||
* the added session position will be set to a value higher than the other session positions.
|
||||
*/
|
||||
suspend fun addSession(sessionData: SessionData)
|
||||
|
||||
/**
|
||||
* Will update the session data matching the userId, except the value of loginTimestamp.
|
||||
* No op if userId is not found in DB.
|
||||
*/
|
||||
suspend fun updateData(sessionData: SessionData)
|
||||
|
||||
/**
|
||||
* Update the user profile info of the session matching the userId.
|
||||
*/
|
||||
suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?)
|
||||
|
||||
/**
|
||||
* Get the session data matching the userId, or null if not found.
|
||||
*/
|
||||
suspend fun getSession(sessionId: String): SessionData?
|
||||
|
||||
/**
|
||||
* Get all sessions ordered by last usage descending.
|
||||
*/
|
||||
suspend fun getAllSessions(): List<SessionData>
|
||||
|
||||
/**
|
||||
* Get the number of sessions.
|
||||
*/
|
||||
suspend fun numberOfSessions(): Int
|
||||
|
||||
/**
|
||||
* Get the latest session, or null if no session exists.
|
||||
*/
|
||||
suspend fun getLatestSession(): SessionData?
|
||||
|
||||
/**
|
||||
* Set the session with [sessionId] as the latest used one.
|
||||
*/
|
||||
suspend fun setLatestSession(sessionId: String)
|
||||
|
||||
/**
|
||||
* Remove the session matching the sessionId.
|
||||
*/
|
||||
suspend fun removeSession(sessionId: String)
|
||||
}
|
||||
|
||||
fun List<SessionData>.toUserList(): List<String> {
|
||||
return map { it.userId }
|
||||
}
|
||||
|
||||
fun Flow<List<SessionData>>.toUserListFlow(): Flow<List<String>> {
|
||||
return map { it.toUserList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a flow emitting the sessionId of the latest session if logged in, null otherwise.
|
||||
*/
|
||||
fun SessionStore.sessionIdFlow(): Flow<String?> {
|
||||
return loggedInStateFlow().map {
|
||||
when (it) {
|
||||
is LoggedInState.LoggedIn -> it.sessionId
|
||||
is LoggedInState.NotLoggedIn -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.api.observer
|
||||
|
||||
interface SessionListener {
|
||||
suspend fun onSessionCreated(userId: String) {}
|
||||
suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.api.observer
|
||||
|
||||
interface SessionObserver {
|
||||
fun addListener(listener: SessionListener)
|
||||
fun removeListener(listener: SessionListener)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
alias(libs.plugins.sqldelight)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.sessionstorage.impl"
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.encryptedDb)
|
||||
api(projects.libraries.sessionStorage.api)
|
||||
implementation(libs.sqldelight.driver.android)
|
||||
implementation(libs.sqlcipher)
|
||||
implementation(libs.sqlite)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(libs.sqldelight.coroutines)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(libs.sqldelight.driver.jvm)
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("SessionDatabase") {
|
||||
// https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/
|
||||
// To generate a .db file from your latest schema, run this task
|
||||
// ./gradlew generateDebugSessionDatabaseSchema
|
||||
// Test migration by running
|
||||
// ./gradlew verifySqlDelightMigration
|
||||
schemaOutputDirectory = File("src/main/sqldelight/databases")
|
||||
verifyMigrations = true
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DatabaseSessionStore(
|
||||
private val database: SessionDatabase,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : SessionStore {
|
||||
private val sessionDataMutex = Mutex()
|
||||
|
||||
override fun loggedInStateFlow(): Flow<LoggedInState> {
|
||||
return database.sessionDataQueries.selectLatest()
|
||||
.asFlow()
|
||||
.mapToOneOrNull(dispatchers.io)
|
||||
.map {
|
||||
if (it == null) {
|
||||
LoggedInState.NotLoggedIn
|
||||
} else {
|
||||
LoggedInState.LoggedIn(
|
||||
sessionId = it.userId,
|
||||
isTokenValid = it.isTokenValid == 1L
|
||||
)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun addSession(sessionData: SessionData) {
|
||||
sessionDataMutex.withLock {
|
||||
val lastUsageIndex = getLastUsageIndex()
|
||||
database.sessionDataQueries.insertSessionData(
|
||||
sessionData
|
||||
.copy(
|
||||
// position value does not really matter, so just use lastUsageIndex + 1 to ensure that
|
||||
// the value is always greater than value of any existing account
|
||||
position = lastUsageIndex + 1,
|
||||
lastUsageIndex = lastUsageIndex + 1,
|
||||
)
|
||||
.toDbModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateData(sessionData: SessionData) {
|
||||
sessionDataMutex.withLock {
|
||||
val result = database.sessionDataQueries.selectByUserId(sessionData.userId)
|
||||
.executeAsOneOrNull()
|
||||
?.toApiModel()
|
||||
|
||||
if (result == null) {
|
||||
Timber.e("User ${sessionData.userId} not found in session database")
|
||||
return
|
||||
}
|
||||
// Copy new data from SDK, but keep application data
|
||||
database.sessionDataQueries.updateSession(
|
||||
sessionData.copy(
|
||||
loginTimestamp = result.loginTimestamp,
|
||||
position = result.position,
|
||||
lastUsageIndex = result.lastUsageIndex,
|
||||
userDisplayName = result.userDisplayName,
|
||||
userAvatarUrl = result.userAvatarUrl,
|
||||
).toDbModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
|
||||
sessionDataMutex.withLock {
|
||||
val result = database.sessionDataQueries.selectByUserId(sessionId)
|
||||
.executeAsOneOrNull()
|
||||
?.toApiModel()
|
||||
if (result == null) {
|
||||
Timber.e("User $sessionId not found in session database")
|
||||
return
|
||||
}
|
||||
database.sessionDataQueries.updateSession(
|
||||
result.copy(
|
||||
userDisplayName = displayName,
|
||||
userAvatarUrl = avatarUrl,
|
||||
).toDbModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setLatestSession(sessionId: String) {
|
||||
val latestSession = getLatestSession()
|
||||
if (latestSession?.userId == sessionId) {
|
||||
// Already the latest session
|
||||
return
|
||||
}
|
||||
val lastUsageIndex = latestSession?.lastUsageIndex ?: 0
|
||||
val result = database.sessionDataQueries.selectByUserId(sessionId)
|
||||
.executeAsOneOrNull()
|
||||
?.toApiModel()
|
||||
if (result == null) {
|
||||
Timber.e("User $sessionId not found in session database")
|
||||
return
|
||||
}
|
||||
sessionDataMutex.withLock {
|
||||
// Update lastUsageIndex of the session
|
||||
database.sessionDataQueries.updateSession(
|
||||
result.copy(
|
||||
lastUsageIndex = lastUsageIndex + 1,
|
||||
).toDbModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLastUsageIndex(): Long {
|
||||
return database.sessionDataQueries.selectLatest()
|
||||
.executeAsOneOrNull()
|
||||
?.lastUsageIndex
|
||||
?: -1L
|
||||
}
|
||||
|
||||
override suspend fun getLatestSession(): SessionData? {
|
||||
return sessionDataMutex.withLock {
|
||||
database.sessionDataQueries.selectLatest()
|
||||
.executeAsOneOrNull()
|
||||
?.toApiModel()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSession(sessionId: String): SessionData? {
|
||||
return sessionDataMutex.withLock {
|
||||
database.sessionDataQueries.selectByUserId(sessionId)
|
||||
.executeAsOneOrNull()
|
||||
?.toApiModel()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAllSessions(): List<SessionData> {
|
||||
return sessionDataMutex.withLock {
|
||||
database.sessionDataQueries.selectAll()
|
||||
.executeAsList()
|
||||
.map { it.toApiModel() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun numberOfSessions(): Int {
|
||||
return sessionDataMutex.withLock {
|
||||
database.sessionDataQueries.count()
|
||||
.executeAsOneOrNull()
|
||||
?.toInt()
|
||||
?: 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun sessionsFlow(): Flow<List<SessionData>> {
|
||||
return database.sessionDataQueries.selectAll()
|
||||
.asFlow()
|
||||
.mapToList(dispatchers.io)
|
||||
.map { it.map { sessionData -> sessionData.toApiModel() } }
|
||||
}
|
||||
|
||||
override suspend fun removeSession(sessionId: String) {
|
||||
sessionDataMutex.withLock {
|
||||
database.sessionDataQueries.removeSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import java.util.Date
|
||||
import io.element.android.libraries.matrix.session.SessionData as DbSessionData
|
||||
|
||||
internal fun SessionData.toDbModel(): DbSessionData {
|
||||
return DbSessionData(
|
||||
userId = userId,
|
||||
deviceId = deviceId,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
oidcData = oidcData,
|
||||
loginTimestamp = loginTimestamp?.time,
|
||||
isTokenValid = if (isTokenValid) 1L else 0L,
|
||||
loginType = loginType.name,
|
||||
passphrase = passphrase,
|
||||
sessionPath = sessionPath,
|
||||
cachePath = cachePath,
|
||||
position = position,
|
||||
lastUsageIndex = lastUsageIndex,
|
||||
userDisplayName = userDisplayName,
|
||||
userAvatarUrl = userAvatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DbSessionData.toApiModel(): SessionData {
|
||||
return SessionData(
|
||||
userId = userId,
|
||||
deviceId = deviceId,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
oidcData = oidcData,
|
||||
loginTimestamp = loginTimestamp?.let { Date(it) },
|
||||
isTokenValid = isTokenValid == 1L,
|
||||
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
|
||||
passphrase = passphrase,
|
||||
sessionPath = sessionPath,
|
||||
cachePath = cachePath,
|
||||
position = position,
|
||||
lastUsageIndex = lastUsageIndex,
|
||||
userDisplayName = userDisplayName,
|
||||
userAvatarUrl = userAvatarUrl,
|
||||
)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl.di
|
||||
|
||||
import android.content.Context
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.sessionstorage.impl.SessionDatabase
|
||||
import io.element.encrypteddb.SqlCipherDriverFactory
|
||||
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
|
||||
|
||||
@BindingContainer
|
||||
@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")
|
||||
|
||||
// Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions
|
||||
val parentDir = secretFile.parentFile
|
||||
if (parentDir != null && !parentDir.exists()) {
|
||||
parentDir.mkdirs()
|
||||
}
|
||||
|
||||
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
|
||||
val driver = SqlCipherDriverFactory(passphraseProvider)
|
||||
.create(SessionDatabase.Schema, "$name.db", context)
|
||||
return SessionDatabase(driver)
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl.observer
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
import io.element.android.libraries.sessionstorage.api.toUserListFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultSessionObserver(
|
||||
private val sessionStore: SessionStore,
|
||||
@AppCoroutineScope
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : SessionObserver {
|
||||
// Keep only the userId
|
||||
private var currentUsers: Set<String>? = null
|
||||
|
||||
init {
|
||||
observeDatabase()
|
||||
}
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<SessionListener>()
|
||||
override fun addListener(listener: SessionListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeListener(listener: SessionListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun observeDatabase() {
|
||||
coroutineScope.launch {
|
||||
withContext(dispatchers.io) {
|
||||
sessionStore.sessionsFlow()
|
||||
.toUserListFlow()
|
||||
.map { it.toSet() }
|
||||
.onEach { newUserSet ->
|
||||
val currentUserSet = currentUsers
|
||||
if (currentUserSet != null) {
|
||||
// Compute diff
|
||||
// Removed user
|
||||
val removedUsers = currentUserSet - newUserSet
|
||||
val wasLastSession = newUserSet.isEmpty()
|
||||
removedUsers.forEach { removedUser ->
|
||||
listeners.onEach { listener ->
|
||||
listener.onSessionDeleted(removedUser, wasLastSession)
|
||||
}
|
||||
}
|
||||
// Added user
|
||||
val addedUsers = newUserSet - currentUserSet
|
||||
addedUsers.forEach { addedUser ->
|
||||
listeners.onEach { listener ->
|
||||
listener.onSessionCreated(addedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentUsers = newUserSet
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+62
@@ -0,0 +1,62 @@
|
||||
--------------------------------------------------------------------
|
||||
-- Current version of the DB is the highest value of filename
|
||||
-- in the folder `sqldelight/databases`.
|
||||
--
|
||||
-- When upgrading the schema, you have to create a file .sqm in the
|
||||
-- `sqldelight/databases` folder and run the following task to
|
||||
-- generate a .db file using the latest schema
|
||||
-- > ./gradlew generateDebugSessionDatabaseSchema
|
||||
--------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE SessionData (
|
||||
userId TEXT NOT NULL PRIMARY KEY,
|
||||
deviceId TEXT NOT NULL,
|
||||
accessToken TEXT NOT NULL,
|
||||
refreshToken TEXT,
|
||||
homeserverUrl TEXT NOT NULL,
|
||||
-- added in version 2
|
||||
loginTimestamp INTEGER,
|
||||
-- added in version 3
|
||||
oidcData TEXT,
|
||||
-- added in version 4
|
||||
isTokenValid INTEGER NOT NULL DEFAULT 1,
|
||||
loginType TEXT,
|
||||
-- added in version 5
|
||||
passphrase TEXT,
|
||||
-- added in version 6
|
||||
sessionPath TEXT NOT NULL DEFAULT "",
|
||||
-- added in version 9
|
||||
cachePath TEXT NOT NULL DEFAULT "",
|
||||
-- added in version 10
|
||||
-- position, to be able to sort account by session creation date
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
-- index of the last usage session. Each time the current session change, the index of the current
|
||||
-- session is incremented to the max value + 1 so it becomes the current session
|
||||
lastUsageIndex INTEGER NOT NULL DEFAULT 0,
|
||||
-- user display name
|
||||
userDisplayName TEXT,
|
||||
-- user avatar url
|
||||
userAvatarUrl TEXT
|
||||
);
|
||||
|
||||
|
||||
selectLatest:
|
||||
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1;
|
||||
|
||||
selectAll:
|
||||
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC;
|
||||
|
||||
count:
|
||||
SELECT count(*) FROM SessionData;
|
||||
|
||||
selectByUserId:
|
||||
SELECT * FROM SessionData WHERE userId = ?;
|
||||
|
||||
insertSessionData:
|
||||
INSERT INTO SessionData VALUES ?;
|
||||
|
||||
removeSession:
|
||||
DELETE FROM SessionData WHERE userId = ?;
|
||||
|
||||
updateSession:
|
||||
REPLACE INTO SessionData VALUES ?;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- This file is not striclty necessary, since the first
|
||||
-- version of the DB is 1, so we will never migrate from 0
|
||||
|
||||
CREATE TABLE SessionData (
|
||||
userId TEXT NOT NULL PRIMARY KEY,
|
||||
deviceId TEXT NOT NULL,
|
||||
accessToken TEXT NOT NULL,
|
||||
refreshToken TEXT,
|
||||
homeserverUrl TEXT NOT NULL,
|
||||
slidingSyncProxy TEXT
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migrate DB from version 1
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migrate DB from version 10
|
||||
-- Remove field slidingSyncProxy
|
||||
|
||||
-- Equivalent to (DROP not supported by sqldelight):
|
||||
-- ALTER TABLE SessionData DROP slidingSyncProxy;
|
||||
|
||||
CREATE TABLE SessionData_bak (
|
||||
userId TEXT NOT NULL PRIMARY KEY,
|
||||
deviceId TEXT NOT NULL,
|
||||
accessToken TEXT NOT NULL,
|
||||
refreshToken TEXT,
|
||||
homeserverUrl TEXT NOT NULL,
|
||||
loginTimestamp INTEGER,
|
||||
oidcData TEXT,
|
||||
isTokenValid INTEGER NOT NULL DEFAULT 1,
|
||||
loginType TEXT,
|
||||
passphrase TEXT,
|
||||
sessionPath TEXT NOT NULL DEFAULT "",
|
||||
cachePath TEXT NOT NULL DEFAULT "",
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
lastUsageIndex INTEGER NOT NULL DEFAULT 0,
|
||||
userDisplayName TEXT,
|
||||
userAvatarUrl TEXT
|
||||
);
|
||||
INSERT INTO SessionData_bak SELECT
|
||||
userId,
|
||||
deviceId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
homeserverUrl,
|
||||
loginTimestamp,
|
||||
oidcData,
|
||||
isTokenValid,
|
||||
loginType,
|
||||
passphrase,
|
||||
sessionPath,
|
||||
cachePath,
|
||||
position,
|
||||
lastUsageIndex,
|
||||
userDisplayName,
|
||||
userAvatarUrl FROM SessionData;
|
||||
DROP TABLE SessionData;
|
||||
ALTER TABLE SessionData_bak RENAME TO SessionData;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migrate DB from version 2
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN oidcData TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migrate DB from version 3
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN isTokenValid INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE SessionData ADD COLUMN loginType TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migrate DB from version 4
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN passphrase TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migrate DB from version 5
|
||||
-- For users migrating previously logged in sessions, we force them to verify them too
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN needsVerification INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migrate DB from version 6
|
||||
-- Remove DB value for verified status, we're back to using the Rust SDK as a source of truth
|
||||
|
||||
CREATE TABLE SessionData_bak (
|
||||
userId TEXT NOT NULL PRIMARY KEY,
|
||||
deviceId TEXT NOT NULL,
|
||||
accessToken TEXT NOT NULL,
|
||||
refreshToken TEXT,
|
||||
homeserverUrl TEXT NOT NULL,
|
||||
slidingSyncProxy TEXT,
|
||||
loginTimestamp INTEGER,
|
||||
oidcData TEXT,
|
||||
isTokenValid INTEGER NOT NULL DEFAULT 1,
|
||||
loginType TEXT,
|
||||
passphrase TEXT
|
||||
);
|
||||
|
||||
INSERT INTO SessionData_bak SELECT userId, deviceId, accessToken, refreshToken, homeserverUrl, slidingSyncProxy, loginTimestamp, oidcData, isTokenValid, loginType, passphrase FROM SessionData;
|
||||
DROP TABLE SessionData;
|
||||
ALTER TABLE SessionData_bak RENAME TO SessionData;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migrate DB from version 7
|
||||
-- Add sessionPath so we can track the anonymized path for the session files dir
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN sessionPath TEXT NOT NULL DEFAULT "";
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migrate DB from version 8
|
||||
-- Add cachePath so we can track the anonymized path for the session cache dir
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN cachePath TEXT NOT NULL DEFAULT "";
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migrate DB from version 9
|
||||
-- Add position to be able to sort account by session creation date
|
||||
-- Add lastUsageIndex so we can restore the last session and switch to another one
|
||||
-- Add display name and avatar url of the user so that we can display a list of accounts.
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE SessionData ADD COLUMN lastUsageIndex INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE SessionData ADD COLUMN userDisplayName TEXT;
|
||||
ALTER TABLE SessionData ADD COLUMN userAvatarUrl TEXT;
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl
|
||||
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.session.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DatabaseSessionStoreTest {
|
||||
private lateinit var database: SessionDatabase
|
||||
private lateinit var databaseSessionStore: DatabaseSessionStore
|
||||
|
||||
private val aSessionData = aDbSessionData()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Before
|
||||
fun setup() {
|
||||
// Initialise in memory SQLite driver
|
||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||
SessionDatabase.Schema.create(driver)
|
||||
|
||||
database = SessionDatabase(driver)
|
||||
databaseSessionStore = DatabaseSessionStore(
|
||||
database = database,
|
||||
dispatchers = CoroutineDispatchers(
|
||||
io = UnconfinedTestDispatcher(),
|
||||
computation = UnconfinedTestDispatcher(),
|
||||
main = UnconfinedTestDispatcher(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addSession persists the SessionData into the DB`() = runTest {
|
||||
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull()
|
||||
|
||||
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||
|
||||
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData)
|
||||
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
|
||||
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loggedInStateFlow emits LoggedIn while there are sessions in the DB`() = runTest {
|
||||
databaseSessionStore.loggedInStateFlow().test {
|
||||
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
|
||||
// Add a second session
|
||||
databaseSessionStore.addSession(aSessionData.copy(userId = "otherUserId").toApiModel())
|
||||
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = "otherUserId", isTokenValid = true))
|
||||
// Remove the second session
|
||||
databaseSessionStore.removeSession("otherUserId")
|
||||
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
|
||||
// Remove the first session
|
||||
databaseSessionStore.removeSession(aSessionData.userId)
|
||||
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
}
|
||||
}
|
||||
|
||||
@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()?.toDbModel()
|
||||
|
||||
assertThat(latestSession).isEqualTo(aSessionData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAllSessions should return all the sessions`() = runTest {
|
||||
val noSessions = databaseSessionStore.getAllSessions()
|
||||
assertThat(noSessions).isEmpty()
|
||||
database.sessionDataQueries.insertSessionData(aSessionData)
|
||||
val otherSessionData = aSessionData.copy(userId = "otherUserId")
|
||||
database.sessionDataQueries.insertSessionData(otherSessionData)
|
||||
val allSessions = databaseSessionStore.getAllSessions().map {
|
||||
it.toDbModel()
|
||||
}
|
||||
assertThat(allSessions).isEqualTo(
|
||||
listOf(
|
||||
aSessionData,
|
||||
otherSessionData,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@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)?.toDbModel()
|
||||
|
||||
assertThat(foundSession).isEqualTo(aSessionData)
|
||||
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2)
|
||||
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2)
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateUserProfile does nothing if the session is not found`() = runTest {
|
||||
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
|
||||
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateUserProfile update the data`() = runTest {
|
||||
database.sessionDataQueries.insertSessionData(aSessionData)
|
||||
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
|
||||
val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
|
||||
assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName")
|
||||
assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLatestSession is no op when the session is already the latest session`() = runTest {
|
||||
database.sessionDataQueries.insertSessionData(aSessionData)
|
||||
val session = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
|
||||
assertThat(session.lastUsageIndex).isEqualTo(0)
|
||||
assertThat(session.position).isEqualTo(0)
|
||||
databaseSessionStore.setLatestSession(aSessionData.userId)
|
||||
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne().lastUsageIndex).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLatestSession is no op when the session is not found`() = runTest {
|
||||
databaseSessionStore.setLatestSession(aSessionData.userId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi session test`() = runTest {
|
||||
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||
val session = databaseSessionStore.getSession(aSessionData.userId)!!
|
||||
assertThat(session.lastUsageIndex).isEqualTo(0)
|
||||
assertThat(session.position).isEqualTo(0)
|
||||
val secondSessionData = aSessionData.copy(
|
||||
userId = "otherUserId",
|
||||
position = 1,
|
||||
lastUsageIndex = 1,
|
||||
)
|
||||
databaseSessionStore.addSession(secondSessionData.toApiModel())
|
||||
val secondSession = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
|
||||
assertThat(secondSession.lastUsageIndex).isEqualTo(1)
|
||||
assertThat(secondSession.position).isEqualTo(1)
|
||||
// Set the first session as the latest
|
||||
databaseSessionStore.setLatestSession(aSessionData.userId)
|
||||
val firstSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
|
||||
assertThat(firstSession.lastUsageIndex).isEqualTo(2)
|
||||
assertThat(firstSession.position).isEqualTo(0)
|
||||
// Check that the second session has not been altered
|
||||
val secondSession2 = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
|
||||
assertThat(secondSession2.lastUsageIndex).isEqualTo(1)
|
||||
assertThat(secondSession2.position).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test sessionsFlow()`() = runTest {
|
||||
databaseSessionStore.sessionsFlow().test {
|
||||
assertThat(awaitItem()).isEmpty()
|
||||
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||
assertThat(awaitItem().size).isEqualTo(1)
|
||||
val secondSessionData = aSessionData.copy(
|
||||
userId = "otherUserId",
|
||||
position = 1,
|
||||
lastUsageIndex = 1,
|
||||
)
|
||||
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
|
||||
databaseSessionStore.addSession(secondSessionData.toApiModel())
|
||||
assertThat(awaitItem().size).isEqualTo(2)
|
||||
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2)
|
||||
databaseSessionStore.removeSession(aSessionData.userId)
|
||||
assertThat(awaitItem().size).isEqualTo(1)
|
||||
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
|
||||
databaseSessionStore.removeSession(secondSessionData.userId)
|
||||
assertThat(awaitItem()).isEmpty()
|
||||
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update session update all fields except info used by the application`() = runTest {
|
||||
val firstSessionData = SessionData(
|
||||
userId = "userId",
|
||||
deviceId = "deviceId",
|
||||
accessToken = "accessToken",
|
||||
refreshToken = "refreshToken",
|
||||
homeserverUrl = "homeserverUrl",
|
||||
loginTimestamp = 1,
|
||||
oidcData = "aOidcData",
|
||||
isTokenValid = 1,
|
||||
loginType = null,
|
||||
passphrase = "aPassphrase",
|
||||
sessionPath = "sessionPath",
|
||||
cachePath = "cachePath",
|
||||
position = 0,
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = "userDisplayName",
|
||||
userAvatarUrl = "userAvatarUrl",
|
||||
)
|
||||
val secondSessionData = SessionData(
|
||||
userId = "userId",
|
||||
deviceId = "deviceIdAltered",
|
||||
accessToken = "accessTokenAltered",
|
||||
refreshToken = "refreshTokenAltered",
|
||||
homeserverUrl = "homeserverUrlAltered",
|
||||
loginTimestamp = 2,
|
||||
oidcData = "aOidcDataAltered",
|
||||
isTokenValid = 1,
|
||||
loginType = null,
|
||||
passphrase = "aPassphraseAltered",
|
||||
sessionPath = "sessionPathAltered",
|
||||
cachePath = "cachePathAltered",
|
||||
position = 1,
|
||||
lastUsageIndex = 1,
|
||||
userDisplayName = "userDisplayNameAltered",
|
||||
userAvatarUrl = "userAvatarUrlAltered",
|
||||
)
|
||||
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
|
||||
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
|
||||
|
||||
database.sessionDataQueries.insertSessionData(firstSessionData)
|
||||
databaseSessionStore.updateData(secondSessionData.toApiModel())
|
||||
|
||||
// Get the altered session
|
||||
val alteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel()
|
||||
|
||||
assertThat(alteredSession.userId).isEqualTo(secondSessionData.userId)
|
||||
assertThat(alteredSession.deviceId).isEqualTo(secondSessionData.deviceId)
|
||||
assertThat(alteredSession.accessToken).isEqualTo(secondSessionData.accessToken)
|
||||
assertThat(alteredSession.refreshToken).isEqualTo(secondSessionData.refreshToken)
|
||||
assertThat(alteredSession.homeserverUrl).isEqualTo(secondSessionData.homeserverUrl)
|
||||
// Check that alteredSession.loginTimestamp is not altered, so equal to firstSessionData.loginTimestamp
|
||||
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
|
||||
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
|
||||
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
|
||||
// Check that application data have not been altered
|
||||
assertThat(alteredSession.position).isEqualTo(firstSessionData.position)
|
||||
assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex)
|
||||
assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName)
|
||||
assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update data, session not found`() = runTest {
|
||||
val firstSessionData = SessionData(
|
||||
userId = "userId",
|
||||
deviceId = "deviceId",
|
||||
accessToken = "accessToken",
|
||||
refreshToken = "refreshToken",
|
||||
homeserverUrl = "homeserverUrl",
|
||||
loginTimestamp = 1,
|
||||
oidcData = "aOidcData",
|
||||
isTokenValid = 1,
|
||||
loginType = LoginType.PASSWORD.name,
|
||||
passphrase = "aPassphrase",
|
||||
sessionPath = "sessionPath",
|
||||
cachePath = "cachePath",
|
||||
position = 0,
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = "userDisplayName",
|
||||
userAvatarUrl = "userAvatarUrl",
|
||||
)
|
||||
val secondSessionData = SessionData(
|
||||
userId = "userIdUnknown",
|
||||
deviceId = "deviceIdAltered",
|
||||
accessToken = "accessTokenAltered",
|
||||
refreshToken = "refreshTokenAltered",
|
||||
homeserverUrl = "homeserverUrlAltered",
|
||||
loginTimestamp = 2,
|
||||
oidcData = "aOidcDataAltered",
|
||||
isTokenValid = 1,
|
||||
loginType = LoginType.PASSWORD.name,
|
||||
passphrase = "aPassphraseAltered",
|
||||
sessionPath = "sessionPathAltered",
|
||||
cachePath = "cachePathAltered",
|
||||
position = 1,
|
||||
lastUsageIndex = 1,
|
||||
userDisplayName = "userDisplayNameAltered",
|
||||
userAvatarUrl = "userAvatarUrlAltered",
|
||||
)
|
||||
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
|
||||
|
||||
database.sessionDataQueries.insertSessionData(firstSessionData)
|
||||
databaseSessionStore.updateData(secondSessionData.toApiModel())
|
||||
|
||||
// Get the session and check that it has not been altered
|
||||
val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel()
|
||||
|
||||
assertThat(notAlteredSession).isEqualTo(firstSessionData)
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl
|
||||
|
||||
import io.element.android.libraries.matrix.session.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||
|
||||
internal fun aDbSessionData(
|
||||
userId: String = "userId",
|
||||
) = SessionData(
|
||||
userId = userId,
|
||||
deviceId = "deviceId",
|
||||
accessToken = "accessToken",
|
||||
refreshToken = "refreshToken",
|
||||
homeserverUrl = "homeserverUrl",
|
||||
loginTimestamp = null,
|
||||
oidcData = "aOidcData",
|
||||
isTokenValid = 1,
|
||||
loginType = LoginType.UNKNOWN.name,
|
||||
passphrase = null,
|
||||
sessionPath = "sessionPath",
|
||||
cachePath = "cachePath",
|
||||
position = 0,
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
)
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl.observer
|
||||
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.sessionstorage.impl.DatabaseSessionStore
|
||||
import io.element.android.libraries.sessionstorage.impl.SessionDatabase
|
||||
import io.element.android.libraries.sessionstorage.impl.aDbSessionData
|
||||
import io.element.android.libraries.sessionstorage.impl.toApiModel
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DefaultSessionObserverTest {
|
||||
private lateinit var database: SessionDatabase
|
||||
private lateinit var databaseSessionStore: DatabaseSessionStore
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Before
|
||||
fun setup() {
|
||||
// Initialise in memory SQLite driver
|
||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||
SessionDatabase.Schema.create(driver)
|
||||
database = SessionDatabase(driver)
|
||||
databaseSessionStore = DatabaseSessionStore(
|
||||
database = database,
|
||||
dispatchers = CoroutineDispatchers(
|
||||
io = UnconfinedTestDispatcher(),
|
||||
computation = UnconfinedTestDispatcher(),
|
||||
main = UnconfinedTestDispatcher(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding data invokes onSessionCreated`() = runTest {
|
||||
val sessionData = aDbSessionData()
|
||||
val sut = createDefaultSessionObserver()
|
||||
runCurrent()
|
||||
val listener = TestSessionListener()
|
||||
sut.addListener(listener)
|
||||
databaseSessionStore.addSession(sessionData.toApiModel())
|
||||
listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId))
|
||||
sut.removeListener(listener)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding and deleting data invokes onSessionCreated and onSessionDeleted`() = runTest {
|
||||
val sessionData = aDbSessionData()
|
||||
val sut = createDefaultSessionObserver()
|
||||
runCurrent()
|
||||
val listener = TestSessionListener()
|
||||
sut.addListener(listener)
|
||||
databaseSessionStore.addSession(sessionData.toApiModel())
|
||||
listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId))
|
||||
databaseSessionStore.removeSession(sessionData.userId)
|
||||
listener.assertEvents(
|
||||
TestSessionListener.Event.Created(sessionData.userId),
|
||||
TestSessionListener.Event.Deleted(sessionData.userId, true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding and deleting data twice invokes onSessionCreated and onSessionDeleted`() = runTest {
|
||||
val sessionData1 = aDbSessionData(userId = "user1")
|
||||
val sessionData2 = aDbSessionData(userId = "user2")
|
||||
val sut = createDefaultSessionObserver()
|
||||
runCurrent()
|
||||
val listener = TestSessionListener()
|
||||
sut.addListener(listener)
|
||||
databaseSessionStore.addSession(sessionData1.toApiModel())
|
||||
databaseSessionStore.addSession(sessionData2.toApiModel())
|
||||
databaseSessionStore.removeSession(sessionData2.userId)
|
||||
databaseSessionStore.removeSession(sessionData1.userId)
|
||||
listener.assertEvents(
|
||||
TestSessionListener.Event.Created(sessionData1.userId),
|
||||
TestSessionListener.Event.Created(sessionData2.userId),
|
||||
TestSessionListener.Event.Deleted(sessionData2.userId, wasLastSession = false),
|
||||
TestSessionListener.Event.Deleted(sessionData1.userId, wasLastSession = true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createDefaultSessionObserver(): DefaultSessionObserver {
|
||||
return DefaultSessionObserver(
|
||||
sessionStore = databaseSessionStore,
|
||||
coroutineScope = backgroundScope,
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.impl.observer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
|
||||
class TestSessionListener : SessionListener {
|
||||
sealed interface Event {
|
||||
data class Created(val userId: String) : Event
|
||||
data class Deleted(val userId: String, val wasLastSession: Boolean) : Event
|
||||
}
|
||||
|
||||
private val trackRecord: MutableList<Event> = mutableListOf()
|
||||
|
||||
override suspend fun onSessionCreated(userId: String) {
|
||||
trackRecord.add(Event.Created(userId))
|
||||
}
|
||||
|
||||
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||
trackRecord.add(Event.Deleted(userId, wasLastSession))
|
||||
}
|
||||
|
||||
fun assertEvents(vararg events: Event) {
|
||||
assertThat(trackRecord).containsExactly(*events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.sessionstorage.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.test
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class InMemorySessionStore(
|
||||
initialList: List<SessionData> = emptyList(),
|
||||
private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") },
|
||||
private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") },
|
||||
) : SessionStore {
|
||||
private val sessionDataListFlow = MutableStateFlow(initialList)
|
||||
|
||||
override fun loggedInStateFlow(): Flow<LoggedInState> {
|
||||
return sessionDataListFlow.map {
|
||||
if (it.isEmpty()) {
|
||||
LoggedInState.NotLoggedIn
|
||||
} else {
|
||||
it.first().let { sessionData ->
|
||||
LoggedInState.LoggedIn(
|
||||
sessionId = sessionData.userId,
|
||||
isTokenValid = sessionData.isTokenValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun sessionsFlow(): Flow<List<SessionData>> = sessionDataListFlow.asStateFlow()
|
||||
|
||||
override suspend fun addSession(sessionData: SessionData) {
|
||||
val currentList = sessionDataListFlow.value.toMutableList()
|
||||
currentList.removeAll { it.userId == sessionData.userId }
|
||||
currentList.add(sessionData)
|
||||
sessionDataListFlow.value = currentList
|
||||
}
|
||||
|
||||
override suspend fun updateData(sessionData: SessionData) {
|
||||
val currentList = sessionDataListFlow.value.toMutableList()
|
||||
val index = currentList.indexOfFirst { it.userId == sessionData.userId }
|
||||
if (index != -1) {
|
||||
currentList[index] = sessionData
|
||||
sessionDataListFlow.value = currentList
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
|
||||
updateUserProfileResult(sessionId, displayName, avatarUrl)
|
||||
}
|
||||
|
||||
override suspend fun getSession(sessionId: String): SessionData? {
|
||||
return sessionDataListFlow.value.firstOrNull { it.userId == sessionId }
|
||||
}
|
||||
|
||||
override suspend fun getAllSessions(): List<SessionData> {
|
||||
return sessionDataListFlow.value
|
||||
}
|
||||
|
||||
override suspend fun numberOfSessions(): Int {
|
||||
return sessionDataListFlow.value.size
|
||||
}
|
||||
|
||||
override suspend fun getLatestSession(): SessionData? {
|
||||
return sessionDataListFlow.value.firstOrNull()
|
||||
}
|
||||
|
||||
override suspend fun setLatestSession(sessionId: String) {
|
||||
setLatestSessionResult(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun removeSession(sessionId: String) {
|
||||
val currentList = sessionDataListFlow.value.toMutableList()
|
||||
currentList.removeAll { it.userId == sessionId }
|
||||
sessionDataListFlow.value = currentList
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.test
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
|
||||
fun aSessionData(
|
||||
sessionId: String = "@alice:server.org",
|
||||
deviceId: String = "aDeviceId",
|
||||
isTokenValid: Boolean = false,
|
||||
sessionPath: String = "/a/path/to/a/session",
|
||||
cachePath: String = "/a/path/to/a/cache",
|
||||
accessToken: String = "anAccessToken",
|
||||
refreshToken: String? = "aRefreshToken",
|
||||
position: Long = 0,
|
||||
lastUsageIndex: Long = 0,
|
||||
userDisplayName: String? = null,
|
||||
userAvatarUrl: String? = null,
|
||||
): SessionData {
|
||||
return SessionData(
|
||||
userId = sessionId,
|
||||
deviceId = deviceId,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = "aHomeserverUrl",
|
||||
oidcData = null,
|
||||
loginTimestamp = null,
|
||||
isTokenValid = isTokenValid,
|
||||
loginType = LoginType.UNKNOWN,
|
||||
passphrase = null,
|
||||
sessionPath = sessionPath,
|
||||
cachePath = cachePath,
|
||||
position = position,
|
||||
lastUsageIndex = lastUsageIndex,
|
||||
userDisplayName = userDisplayName,
|
||||
userAvatarUrl = userAvatarUrl,
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.test.observer
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
|
||||
class FakeSessionObserver : SessionObserver {
|
||||
private val _listeners = mutableListOf<SessionListener>()
|
||||
|
||||
val listeners: List<SessionListener>
|
||||
get() = _listeners
|
||||
|
||||
override fun addListener(listener: SessionListener) {
|
||||
_listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeListener(listener: SessionListener) {
|
||||
_listeners.remove(listener)
|
||||
}
|
||||
|
||||
suspend fun onSessionCreated(userId: String) {
|
||||
listeners.forEach { it.onSessionCreated(userId) }
|
||||
}
|
||||
|
||||
suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean = true) {
|
||||
listeners.forEach { it.onSessionDeleted(userId, wasLastSession = wasLastSession) }
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.sessionstorage.test.observer
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
|
||||
class NoOpSessionObserver : SessionObserver {
|
||||
override fun addListener(listener: SessionListener) = Unit
|
||||
override fun removeListener(listener: SessionListener) = Unit
|
||||
}
|
||||
Reference in New Issue
Block a user