Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal interface AccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(account: DbAccount)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(accounts: List<DbAccount>)

@Query("UPDATE DbAccount SET last_active = :lastActive WHERE account_key = :accountKey")
suspend fun setLastActive(
accountKey: MicroBlogKey,
Expand All @@ -36,4 +39,7 @@ internal interface AccountDao {
accountKey: MicroBlogKey,
credentialJson: String,
)

@Query("SELECT * FROM DbAccount WHERE account_key in (:accountKeys)")
fun get(accountKeys: List<MicroBlogKey>): Flow<List<DbAccount>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.model.PlatformType
import kotlinx.serialization.Serializable

@Entity
@Serializable
internal data class DbAccount(
@PrimaryKey val account_key: MicroBlogKey,
val credential_json: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package dev.dimension.flare.ui.presenter.settings

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import dev.dimension.flare.data.database.app.AppDatabase
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.ui.model.UiState
import dev.dimension.flare.ui.model.collectAsUiState
import dev.dimension.flare.ui.presenter.PresenterBase
import io.ktor.util.GZipEncoder
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.toByteArray
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.map
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.CoroutineContext

public class ExportAccountPresenter(
private val accountKeys: ImmutableList<MicroBlogKey>,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : PresenterBase<ExportAccountPresenter.State>(),
KoinComponent {
private val appDatabase: AppDatabase by inject()

public interface State {
public val data: UiState<String>
}

@OptIn(ExperimentalSerializationApi::class)
private val dataFlow by lazy {
appDatabase.accountDao().get(accountKeys).map { accounts ->
val protoBuf = ProtoBuf.encodeToByteArray(accounts)
val channel = ByteReadChannel(protoBuf)
val gzip = GZipEncoder.encode(source = channel, coroutineContext = coroutineContext)
gzip.toByteArray().toHexString()
}
}

@Composable
override fun body(): State {
val data by dataFlow.collectAsUiState()
return object : State {
override val data = data
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dev.dimension.flare.ui.presenter.settings

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import dev.dimension.flare.data.database.app.AppDatabase
import dev.dimension.flare.data.database.app.model.DbAccount
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.ui.model.UiState
import dev.dimension.flare.ui.model.collectAsUiState
import dev.dimension.flare.ui.presenter.PresenterBase
import io.ktor.util.GZipEncoder
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.toByteArray
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.CoroutineContext

public class ImportAccountPresenter(
private val data: String,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : PresenterBase<ImportAccountPresenter.State>(),
KoinComponent {
private val appDatabase: AppDatabase by inject()

public interface State {
public val importedAccount: UiState<ImmutableList<MicroBlogKey>>
}

@OptIn(ExperimentalSerializationApi::class)
private val dataFlow by lazy {
flow {
val byteArray = data.hexToByteArray()
val channel = ByteReadChannel(byteArray)
val gzip = GZipEncoder.decode(source = channel, coroutineContext = coroutineContext)
val gzipByteArray = gzip.toByteArray()
val accounts = ProtoBuf.decodeFromByteArray<List<DbAccount>>(gzipByteArray)
appDatabase.accountDao().insert(accounts)
emit(accounts.map { it.account_key }.toImmutableList())
}
}

@Composable
override fun body(): State {
val importedAccount by dataFlow.collectAsUiState()
return object : State {
override val importedAccount = importedAccount
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class, ExperimentalStdlibApi::class)

package dev.dimension.flare.ui.presenter.settings

import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import dev.dimension.flare.data.database.app.AppDatabase
import dev.dimension.flare.data.database.app.model.DbAccount
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.model.PlatformType
import dev.dimension.flare.ui.model.takeSuccess
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals

@OptIn(ExperimentalCoroutinesApi::class)
class ExportAccountPresenterTest {
private lateinit var db: AppDatabase

@BeforeTest
fun setup() {
val db =
Room
.inMemoryDatabaseBuilder<AppDatabase>()
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.Unconfined)
.build()
this.db = db

startKoin {
modules(
module {
single { db }
},
)
}
}

@AfterTest
fun tearDown() {
db.close()
stopKoin()
}

@Test
fun testExportAccount() =
runTest {
// Given
val accountKey1 = MicroBlogKey("user1", "mastodon.social")
val account1 =
DbAccount(
account_key = accountKey1,
credential_json = "{}",
platform_type = PlatformType.Mastodon,
last_active = 1000L,
)

val accountKey2 = MicroBlogKey("user2", "bsky.app")
val account2 =
DbAccount(
account_key = accountKey2,
credential_json = "{\"session\":\"abc\"}",
platform_type = PlatformType.Bluesky,
last_active = 2000L,
)

db.accountDao().insert(listOf(account1, account2))

val exportKeys = listOf(accountKey1, accountKey2).toImmutableList()
val presenter = ExportAccountPresenter(exportKeys, coroutineContext)

var result: String? = null
val job =
launch {
moleculeFlow(mode = RecompositionMode.Immediate) {
presenter.body()
}.collect { state ->
result = state.data.takeSuccess()
}
}

advanceUntilIdle()
job.cancel()

assertEquals(
result,
"1f8b08000000000000006352e692e0622d2d4e2d3214e2cf4d2c2ec94fc9cfd32bce4fce4ccc1162aaae95605078c1aecd2508516324c491549c5da9975850202458ad549c5a5c9c999fa764a5949894ac542bc1a470811f0078af96a051000000",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class, ExperimentalStdlibApi::class)

package dev.dimension.flare.ui.presenter.settings

import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import dev.dimension.flare.data.database.app.AppDatabase
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.ui.model.takeSuccess
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@OptIn(ExperimentalCoroutinesApi::class)
class ImportAccountPresenterTest {
private lateinit var db: AppDatabase

@BeforeTest
fun setup() {
val db =
Room
.inMemoryDatabaseBuilder<AppDatabase>()
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.Unconfined)
.build()
this.db = db

startKoin {
modules(
module {
single { db }
},
)
}
}

@AfterTest
fun tearDown() {
db.close()
stopKoin()
}

@Test
fun testImportAccount() =
runTest {
// Given
val data =
"1f8b08000000000000006352e692e0622d2d4e2d3214e2cf4d2c2ec94fc9" +
"cfd32bce4fce4ccc1162aaae95605078c1aecd2508516324c491549c5da997585" +
"0202458ad549c5a5c9c999fa764a5949894ac542bc1a470811f0078af96a051000000"
val presenter = ImportAccountPresenter(data, coroutineContext)

var result: List<MicroBlogKey>? = null
val job =
launch {
moleculeFlow(mode = RecompositionMode.Immediate) {
presenter.body()
}.collect { state ->
result = state.importedAccount.takeSuccess()
}
}

advanceUntilIdle()
job.cancel()

// Assert
val expectedKeys =
listOf(
MicroBlogKey("user1", "mastodon.social"),
MicroBlogKey("user2", "bsky.app"),
)

assertEquals(expectedKeys, result)

// Verify DB insertion
val storedAccounts = db.accountDao().allAccounts().first()
assertEquals(2, storedAccounts.size)
assertTrue(storedAccounts.any { it.account_key == expectedKeys[0] })
assertTrue(storedAccounts.any { it.account_key == expectedKeys[1] })
}
}
Loading