diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/AccountDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/AccountDao.kt index 7f96adaef..967ce0f71 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/AccountDao.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/AccountDao.kt @@ -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) + @Query("UPDATE DbAccount SET last_active = :lastActive WHERE account_key = :accountKey") suspend fun setLastActive( accountKey: MicroBlogKey, @@ -36,4 +39,7 @@ internal interface AccountDao { accountKey: MicroBlogKey, credentialJson: String, ) + + @Query("SELECT * FROM DbAccount WHERE account_key in (:accountKeys)") + fun get(accountKeys: List): Flow> } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbAccount.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbAccount.kt index 7c1849995..b7d370cd6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbAccount.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbAccount.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ExportAccountPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ExportAccountPresenter.kt new file mode 100644 index 000000000..b29553ce5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ExportAccountPresenter.kt @@ -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, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : PresenterBase(), + KoinComponent { + private val appDatabase: AppDatabase by inject() + + public interface State { + public val data: UiState + } + + @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 + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAccountPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAccountPresenter.kt new file mode 100644 index 000000000..72b38a6fe --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAccountPresenter.kt @@ -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(), + KoinComponent { + private val appDatabase: AppDatabase by inject() + + public interface State { + public val importedAccount: UiState> + } + + @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>(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 + } + } +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/ExportAccountPresenterTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/ExportAccountPresenterTest.kt new file mode 100644 index 000000000..8f9611a69 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/ExportAccountPresenterTest.kt @@ -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() + .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", + ) + } +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAccountPresenterTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAccountPresenterTest.kt new file mode 100644 index 000000000..0fc6159ac --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAccountPresenterTest.kt @@ -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() + .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? = 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] }) + } +}