First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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,
)
}
@@ -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)
}
}
@@ -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()
}
}
}
}
@@ -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;
@@ -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)
}
}
@@ -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,
)
@@ -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),
)
}
}
@@ -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)
}
}