diff --git a/README.md b/README.md index d4ac7fdf..ae8d6a00 100644 --- a/README.md +++ b/README.md @@ -860,6 +860,7 @@ Current Proto API surface: | Category | APIs | |----------|------| | Lifecycle | `close()` | +| Byte backup | `exportAsByteArray`, `importFromByteArray` | | Whole message | `data()` | | Raw fields | `field()` | | Enum fields | `enumField`, `nullableEnumField`, `enumSetField` | @@ -875,6 +876,22 @@ val dataPref: ProtoPreference = protoDatastore.data() // Then use get(), set(), asFlow(), etc. just like Preferences DataStore ``` +#### Byte Backup and Restore + +Factory-created proto datastores can export and import the raw datastore file bytes: + +```kotlin +val backupBytes: ByteArray = protoDatastore.exportAsByteArray() + +protoDatastore.importFromByteArray(backupBytes) +``` + +`exportAsByteArray()` returns an empty `ByteArray` if the backing file has not been created yet. +`importFromByteArray()` decodes the bytes with the datastore's `OkioSerializer` and replaces the +whole proto message. Low-level `GenericProtoDatastore` wrappers only support these APIs when they +are constructed with the serializer and resolved path; otherwise they throw +`UnsupportedOperationException`. + #### Per-Field Access Use `field()` to create a preference for an individual field of the proto message. The `getter` diff --git a/generic-datastore-proto/api/generic-datastore-proto.klib.api b/generic-datastore-proto/api/generic-datastore-proto.klib.api index 2c896e5e..aae9e405 100644 --- a/generic-datastore-proto/api/generic-datastore-proto.klib.api +++ b/generic-datastore-proto/api/generic-datastore-proto.klib.api @@ -23,6 +23,8 @@ abstract interface <#A: kotlin/Any?> io.github.arthurkun.generic.datastore.proto abstract fun <#A1: kotlin/Enum<#A1>> nullableEnumField(kotlin/Array<#A1>, kotlin/Function1<#A, kotlin/String?>, kotlin/Function2<#A, kotlin/String?, #A>): io.github.arthurkun.generic.datastore.proto/ProtoPreference<#A1?> // io.github.arthurkun.generic.datastore.proto/ProtoDatastore.nullableEnumField|nullableEnumField(kotlin.Array<0:0>;kotlin.Function1<1:0,kotlin.String?>;kotlin.Function2<1:0,kotlin.String?,1:0>){0§>}[0] abstract fun data(): io.github.arthurkun.generic.datastore.proto/ProtoPreference<#A> // io.github.arthurkun.generic.datastore.proto/ProtoDatastore.data|data(){}[0] open fun close() // io.github.arthurkun.generic.datastore.proto/ProtoDatastore.close|close(){}[0] + open suspend fun exportAsByteArray(): kotlin/ByteArray // io.github.arthurkun.generic.datastore.proto/ProtoDatastore.exportAsByteArray|exportAsByteArray(){}[0] + open suspend fun importFromByteArray(kotlin/ByteArray) // io.github.arthurkun.generic.datastore.proto/ProtoDatastore.importFromByteArray|importFromByteArray(kotlin.ByteArray){}[0] } abstract interface <#A: kotlin/Any?> io.github.arthurkun.generic.datastore.proto/ProtoPreference : io.github.arthurkun.generic.datastore.core/DelegatedPreference<#A> // io.github.arthurkun.generic.datastore.proto/ProtoPreference|null[0] @@ -44,6 +46,8 @@ final class <#A: kotlin/Any?> io.github.arthurkun.generic.datastore.proto/Generi final fun <#A1: kotlin/Enum<#A1>> nullableEnumField(kotlin/Array<#A1>, kotlin/Function1<#A, kotlin/String?>, kotlin/Function2<#A, kotlin/String?, #A>): io.github.arthurkun.generic.datastore.proto/ProtoPreference<#A1?> // io.github.arthurkun.generic.datastore.proto/GenericProtoDatastore.nullableEnumField|nullableEnumField(kotlin.Array<0:0>;kotlin.Function1<1:0,kotlin.String?>;kotlin.Function2<1:0,kotlin.String?,1:0>){0§>}[0] final fun close() // io.github.arthurkun.generic.datastore.proto/GenericProtoDatastore.close|close(){}[0] final fun data(): io.github.arthurkun.generic.datastore.proto/ProtoPreference<#A> // io.github.arthurkun.generic.datastore.proto/GenericProtoDatastore.data|data(){}[0] + final suspend fun exportAsByteArray(): kotlin/ByteArray // io.github.arthurkun.generic.datastore.proto/GenericProtoDatastore.exportAsByteArray|exportAsByteArray(){}[0] + final suspend fun importFromByteArray(kotlin/ByteArray) // io.github.arthurkun.generic.datastore.proto/GenericProtoDatastore.importFromByteArray|importFromByteArray(kotlin.ByteArray){}[0] } final fun <#A: kotlin/Any?> io.github.arthurkun.generic.datastore.proto/createProtoDatastore(androidx.datastore.core.okio/OkioSerializer<#A>, #A, kotlin/String, kotlin/String = ..., androidx.datastore.core.handlers/ReplaceFileCorruptionHandler<#A>? = ..., kotlin.collections/List> = ..., kotlinx.coroutines/CoroutineScope? = ..., kotlinx.serialization.json/Json = ..., kotlin/Function0): io.github.arthurkun.generic.datastore.proto/GenericProtoDatastore<#A> // io.github.arthurkun.generic.datastore.proto/createProtoDatastore|createProtoDatastore(androidx.datastore.core.okio.OkioSerializer<0:0>;0:0;kotlin.String;kotlin.String;androidx.datastore.core.handlers.ReplaceFileCorruptionHandler<0:0>?;kotlin.collections.List>;kotlinx.coroutines.CoroutineScope?;kotlinx.serialization.json.Json;kotlin.Function0){0§}[0] diff --git a/generic-datastore-proto/api/jvm/generic-datastore-proto.api b/generic-datastore-proto/api/jvm/generic-datastore-proto.api index 1bc3a42d..39c6a63f 100644 --- a/generic-datastore-proto/api/jvm/generic-datastore-proto.api +++ b/generic-datastore-proto/api/jvm/generic-datastore-proto.api @@ -14,7 +14,9 @@ public final class io/github/arthurkun/generic/datastore/proto/GenericProtoDatas public fun data ()Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public fun enumField (Ljava/lang/Enum;[Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public fun enumSetField (Ljava/util/Set;[Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; + public fun exportAsByteArray (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun field (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; + public fun importFromByteArray ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun kserializedField (Ljava/lang/Object;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public fun kserializedListField (Ljava/util/List;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public fun kserializedSetField (Ljava/util/Set;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; @@ -34,7 +36,9 @@ public abstract interface class io/github/arthurkun/generic/datastore/proto/Prot public abstract fun enumField (Ljava/lang/Enum;[Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public abstract fun enumSetField (Ljava/util/Set;[Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public static synthetic fun enumSetField$default (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Ljava/util/Set;[Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; + public fun exportAsByteArray (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun field (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; + public fun importFromByteArray ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun kserializedField (Ljava/lang/Object;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public static synthetic fun kserializedField$default (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Ljava/lang/Object;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public abstract fun kserializedListField (Ljava/util/List;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; @@ -58,6 +62,8 @@ public abstract interface class io/github/arthurkun/generic/datastore/proto/Prot public final class io/github/arthurkun/generic/datastore/proto/ProtoDatastore$DefaultImpls { public static fun close (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;)V public static synthetic fun enumSetField$default (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Ljava/util/Set;[Ljava/lang/Enum;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; + public static fun exportAsByteArray (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun importFromByteArray (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun kserializedField$default (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Ljava/lang/Object;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public static synthetic fun kserializedListField$default (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Ljava/util/List;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; public static synthetic fun kserializedSetField$default (Lio/github/arthurkun/generic/datastore/proto/ProtoDatastore;Ljava/util/Set;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/json/Json;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/github/arthurkun/generic/datastore/proto/ProtoPreference; diff --git a/generic-datastore-proto/src/androidDeviceTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/AndroidProtoBackupTest.kt b/generic-datastore-proto/src/androidDeviceTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/AndroidProtoBackupTest.kt new file mode 100644 index 00000000..6b7996ae --- /dev/null +++ b/generic-datastore-proto/src/androidDeviceTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/AndroidProtoBackupTest.kt @@ -0,0 +1,52 @@ +package io.github.arthurkun.generic.datastore.proto.backup + +import android.content.Context +import androidx.datastore.core.DataMigration +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.arthurkun.generic.datastore.proto.GenericProtoDatastore +import io.github.arthurkun.generic.datastore.proto.core.AndroidProtoTestHelper +import io.github.arthurkun.generic.datastore.proto.core.TestProtoData +import io.github.arthurkun.generic.datastore.proto.core.TestProtoDataSerializer +import kotlinx.coroutines.test.TestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import java.io.File +import io.github.arthurkun.generic.datastore.proto.createProtoDatastore as createProtoDatastoreFactory + +@RunWith(AndroidJUnit4::class) +class AndroidProtoBackupTest : AbstractProtoBackupTest() { + + private val helper = AndroidProtoTestHelper.standard("test_proto_backup") + + override val protoDatastore get() = helper.protoDatastore + override val testDispatcher: TestDispatcher get() = helper.testDispatcher + + override fun createProtoDatastore( + datastoreName: String, + migrations: List>, + ): GenericProtoDatastore = createProtoDatastoreFactory( + serializer = TestProtoDataSerializer, + defaultValue = TestProtoData(), + migrations = migrations, + producePath = { + val context = ApplicationProvider.getApplicationContext() + context.filesDir.resolve("datastore/$datastoreName.pb").absolutePath + }, + ) + + override fun deleteProtoDatastore(datastoreName: String) { + val context = ApplicationProvider.getApplicationContext() + val dataStoreFile = File(context.filesDir, "datastore/$datastoreName.pb") + if (dataStoreFile.exists()) { + dataStoreFile.delete() + } + } + + @Before + fun setup() = helper.setup() + + @After + fun tearDown() = helper.tearDown() +} diff --git a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/CreateProtoDatastore.kt b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/CreateProtoDatastore.kt index cf292ae0..4ad2b0ea 100644 --- a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/CreateProtoDatastore.kt +++ b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/CreateProtoDatastore.kt @@ -70,6 +70,8 @@ public fun createProtoDatastore( key = nameKey, defaultJson = defaultJson, ownedScope = datastoreScope, + serializer = serializer, + path = path, ) } @@ -107,23 +109,26 @@ public fun createProtoDatastore( produceOkioPath: () -> okio.Path, ): GenericProtoDatastore { val datastoreScope = createDatastoreScope(scope) + val path = produceOkioPath() val datastore = createDataStore( storage = OkioStorage( fileSystem = systemFileSystem, serializer = serializer, - producePath = produceOkioPath, + producePath = { path }, ), corruptionHandler = corruptionHandler, migrations = migrations, scope = datastoreScope, ) - val nameKey = key ?: produceOkioPath().name + val nameKey = key ?: path.name return GenericProtoDatastore( datastore = datastore, defaultValue = defaultValue, key = nameKey, defaultJson = defaultJson, ownedScope = datastoreScope, + serializer = serializer, + path = path, ) } @@ -180,6 +185,8 @@ public fun createProtoDatastore( key = nameKey, defaultJson = defaultJson, ownedScope = datastoreScope, + serializer = serializer, + path = path, ) } @@ -221,11 +228,12 @@ public fun createProtoDatastore( producePath: () -> String, ): GenericProtoDatastore { val datastoreScope = createDatastoreScope(scope) + val path = producePath().toPath() / fileName val datastore = createDataStore( storage = OkioStorage( fileSystem = systemFileSystem, serializer = serializer, - producePath = { producePath().toPath() / fileName }, + producePath = { path }, ), corruptionHandler = corruptionHandler, migrations = migrations, @@ -237,6 +245,8 @@ public fun createProtoDatastore( key = key, defaultJson = defaultJson, ownedScope = datastoreScope, + serializer = serializer, + path = path, ) } diff --git a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/GenericProtoDatastore.kt b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/GenericProtoDatastore.kt index ab1ff1de..e58ae126 100644 --- a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/GenericProtoDatastore.kt +++ b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/GenericProtoDatastore.kt @@ -3,8 +3,11 @@ package io.github.arthurkun.generic.datastore.proto import androidx.datastore.core.DataStore +import androidx.datastore.core.okio.OkioSerializer import io.github.arthurkun.generic.datastore.core.InternalGenericDatastoreApi import io.github.arthurkun.generic.datastore.core.PreferenceDefaults +import io.github.arthurkun.generic.datastore.proto.backup.ProtoBackupCreator +import io.github.arthurkun.generic.datastore.proto.backup.ProtoBackupRestorer import io.github.arthurkun.generic.datastore.proto.core.GenericProtoPreferenceItem import io.github.arthurkun.generic.datastore.proto.custom.ProtoSerialFieldPreference import io.github.arthurkun.generic.datastore.proto.custom.core.enumFieldInternal @@ -23,9 +26,11 @@ import io.github.arthurkun.generic.datastore.proto.custom.set.serializedSetField import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json +import okio.Path /** * A DataStore implementation for Proto DataStore. @@ -39,6 +44,8 @@ import kotlinx.serialization.json.Json * @param datastore The underlying [DataStore] instance. * @param defaultValue The default value for the proto message. * @param ownedScope The scope owned by this wrapper when it creates the underlying [DataStore]. + * @param serializer The serializer used by byte-array restore for factory-created datastores. + * @param path The resolved datastore file path used by byte-array backup for factory-created datastores. */ public class GenericProtoDatastore @InternalGenericDatastoreApi constructor( internal val datastore: DataStore, @@ -46,6 +53,8 @@ public class GenericProtoDatastore @InternalGenericDatastoreApi constructor( private val key: String = "proto_datastore", private val defaultJson: Json = PreferenceDefaults.defaultJson, private val ownedScope: CoroutineScope? = null, + private val serializer: OkioSerializer? = null, + private val path: Path? = null, ) : ProtoDatastore { private val cachedData: ProtoPreference by lazy { @@ -64,6 +73,22 @@ public class GenericProtoDatastore @InternalGenericDatastoreApi constructor( } } + override suspend fun exportAsByteArray(): ByteArray { + val resolvedPath = path + ?: throw UnsupportedOperationException("Byte-array backup requires a resolved datastore path.") + datastore.data.first() + return ProtoBackupCreator(resolvedPath).exportAsByteArray() + } + + override suspend fun importFromByteArray(data: ByteArray) { + val resolvedSerializer = serializer + ?: throw UnsupportedOperationException("Byte-array restore requires an OkioSerializer.") + ProtoBackupRestorer( + datastore = datastore, + serializer = resolvedSerializer, + ).importFromByteArray(data) + } + override fun field( defaultValue: F, getter: (T) -> F, diff --git a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/ProtoDatastore.kt b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/ProtoDatastore.kt index abd13ff8..2498da16 100644 --- a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/ProtoDatastore.kt +++ b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/ProtoDatastore.kt @@ -14,6 +14,30 @@ import kotlinx.serialization.json.Json public interface ProtoDatastore : AutoCloseable { override fun close() {} + /** + * Exports the raw proto datastore file contents as bytes. + * + * Factory-created datastores support this API. Implementations that do not own a resolvable + * datastore file may throw [UnsupportedOperationException]. + * + * @return The current datastore file bytes, or an empty [ByteArray] when the file does not exist. + */ + public suspend fun exportAsByteArray(): ByteArray { + throw UnsupportedOperationException("Byte-array backup is not supported by this ProtoDatastore.") + } + + /** + * Imports raw proto bytes and replaces the whole proto message. + * + * Factory-created datastores support this API by decoding [data] with their [androidx.datastore.core.okio.OkioSerializer]. + * Implementations that cannot decode raw proto bytes may throw [UnsupportedOperationException]. + * + * @param data The serialized proto bytes to import. + */ + public suspend fun importFromByteArray(data: ByteArray) { + throw UnsupportedOperationException("Byte-array restore is not supported by this ProtoDatastore.") + } + /** * Returns the proto message wrapped as a [DelegatedPreference] instance. * diff --git a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/backup/ProtoBackupCreator.kt b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/backup/ProtoBackupCreator.kt new file mode 100644 index 00000000..3ca759ae --- /dev/null +++ b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/backup/ProtoBackupCreator.kt @@ -0,0 +1,24 @@ +package io.github.arthurkun.generic.datastore.proto.backup + +import io.github.arthurkun.generic.datastore.core.systemFileSystem +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import okio.Path + +internal class ProtoBackupCreator( + private val path: Path, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + suspend fun exportAsByteArray(): ByteArray = withContext(ioDispatcher) { + if (!systemFileSystem.exists(path)) { + return@withContext ByteArray(0) + } + + systemFileSystem.read(path) { + readByteArray() + } + } +} diff --git a/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/backup/ProtoBackupRestorer.kt b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/backup/ProtoBackupRestorer.kt new file mode 100644 index 00000000..c977eda4 --- /dev/null +++ b/generic-datastore-proto/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/backup/ProtoBackupRestorer.kt @@ -0,0 +1,16 @@ +package io.github.arthurkun.generic.datastore.proto.backup + +import androidx.datastore.core.DataStore +import androidx.datastore.core.okio.OkioSerializer +import okio.Buffer + +internal class ProtoBackupRestorer( + private val datastore: DataStore, + private val serializer: OkioSerializer, +) { + + suspend fun importFromByteArray(data: ByteArray) { + val imported = serializer.readFrom(Buffer().write(data)) + datastore.updateData { imported } + } +} diff --git a/generic-datastore-proto/src/commonTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/AbstractProtoBackupTest.kt b/generic-datastore-proto/src/commonTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/AbstractProtoBackupTest.kt new file mode 100644 index 00000000..be647d4a --- /dev/null +++ b/generic-datastore-proto/src/commonTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/AbstractProtoBackupTest.kt @@ -0,0 +1,148 @@ +@file:OptIn(io.github.arthurkun.generic.datastore.core.InternalGenericDatastoreApi::class) + +package io.github.arthurkun.generic.datastore.proto.backup + +import androidx.datastore.core.DataMigration +import io.github.arthurkun.generic.datastore.proto.GenericProtoDatastore +import io.github.arthurkun.generic.datastore.proto.ProtoDatastore +import io.github.arthurkun.generic.datastore.proto.core.TestAddress +import io.github.arthurkun.generic.datastore.proto.core.TestProfile +import io.github.arthurkun.generic.datastore.proto.core.TestProtoData +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +abstract class AbstractProtoBackupTest { + + abstract val protoDatastore: GenericProtoDatastore + abstract val testDispatcher: TestDispatcher + + abstract fun createProtoDatastore( + datastoreName: String, + migrations: List> = emptyList(), + ): GenericProtoDatastore + + open fun deleteProtoDatastore(datastoreName: String) = Unit + + @Test + fun exportAsByteArray_fileDoesNotExist_returnsEmptyBytes() = runTest(testDispatcher) { + assertTrue(protoDatastore.exportAsByteArray().isEmpty()) + } + + @Test + fun exportAsByteArray_firstOperation_runsMigrationsBeforeReadingBytes() = runTest(testDispatcher) { + val datastoreName = "test_proto_backup_migration" + val migrated = TestProtoData(id = 10, name = "Migrated") + var cleanUpCalled = false + val migration = object : DataMigration { + override suspend fun shouldMigrate(currentData: TestProtoData): Boolean = true + + override suspend fun migrate(currentData: TestProtoData): TestProtoData = migrated + + override suspend fun cleanUp() { + cleanUpCalled = true + } + } + val datastore = createProtoDatastore( + datastoreName = datastoreName, + migrations = listOf(migration), + ) + + try { + val backup = datastore.exportAsByteArray() + + assertTrue(backup.isNotEmpty()) + assertTrue(cleanUpCalled) + + datastore.data().set(TestProtoData(id = 11, name = "Changed")) + datastore.importFromByteArray(backup) + + assertEquals(migrated, datastore.data().get()) + } finally { + datastore.close() + deleteProtoDatastore(datastoreName) + } + } + + @Test + fun exportAsByteArray_afterSet_returnsNonEmptyBytes() = runTest(testDispatcher) { + protoDatastore.data().set(TestProtoData(id = 1, name = "Alice")) + + val backup = protoDatastore.exportAsByteArray() + + assertTrue(backup.isNotEmpty()) + } + + @Test + fun importFromByteArray_restoresWholeProtoAfterMutation() = runTest(testDispatcher) { + val original = TestProtoData( + id = 2, + name = "Original", + profile = TestProfile( + nickname = "first", + age = 30, + address = TestAddress(street = "One", city = "A", zipCode = "1000"), + ), + ) + protoDatastore.data().set(original) + val backup = protoDatastore.exportAsByteArray() + + protoDatastore.data().set(TestProtoData(id = 3, name = "Changed")) + protoDatastore.importFromByteArray(backup) + + assertEquals(original, protoDatastore.data().get()) + } + + @Test + fun importFromByteArray_replacesWholeProto() = runTest(testDispatcher) { + val imported = TestProtoData(id = 4, name = "Imported") + protoDatastore.data().set(imported) + val backup = protoDatastore.exportAsByteArray() + + val local = TestProtoData( + id = 5, + name = "Local", + profile = TestProfile( + nickname = "local", + age = 40, + address = TestAddress(street = "Two", city = "B", zipCode = "2000"), + ), + ) + protoDatastore.data().set(local) + + protoDatastore.importFromByteArray(backup) + + assertEquals(imported, protoDatastore.data().get()) + } + + @Test + fun factoryCreatedDatastore_supportsByteBackupApisThroughContract() = runTest(testDispatcher) { + val datastore: ProtoDatastore = protoDatastore + val original = TestProtoData(id = 6, name = "Contract") + datastore.data().set(original) + val backup = datastore.exportAsByteArray() + + datastore.data().set(TestProtoData(id = 7, name = "Mutated")) + datastore.importFromByteArray(backup) + + assertEquals(original, datastore.data().get()) + } + + @Test + fun directGenericProtoDatastore_withoutBackupWiring_throwsUnsupported() = runTest(testDispatcher) { + val directDatastore = GenericProtoDatastore( + datastore = protoDatastore.datastore, + defaultValue = TestProtoData(), + ) + + assertFailsWith { + directDatastore.exportAsByteArray() + } + assertFailsWith { + directDatastore.importFromByteArray(ByteArray(0)) + } + } +} diff --git a/generic-datastore-proto/src/iosSimulatorArm64Test/kotlin/io/github/arthurkun/generic/datastore/proto/backup/IosProtoBackupTest.kt b/generic-datastore-proto/src/iosSimulatorArm64Test/kotlin/io/github/arthurkun/generic/datastore/proto/backup/IosProtoBackupTest.kt new file mode 100644 index 00000000..77d9ca64 --- /dev/null +++ b/generic-datastore-proto/src/iosSimulatorArm64Test/kotlin/io/github/arthurkun/generic/datastore/proto/backup/IosProtoBackupTest.kt @@ -0,0 +1,58 @@ +package io.github.arthurkun.generic.datastore.proto.backup + +import androidx.datastore.core.DataMigration +import io.github.arthurkun.generic.datastore.proto.GenericProtoDatastore +import io.github.arthurkun.generic.datastore.proto.core.IosProtoTestHelper +import io.github.arthurkun.generic.datastore.proto.core.TestProtoData +import io.github.arthurkun.generic.datastore.proto.core.TestProtoDataSerializer +import kotlinx.coroutines.test.TestDispatcher +import platform.Foundation.NSFileManager +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSUUID +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import io.github.arthurkun.generic.datastore.proto.createProtoDatastore as createProtoDatastoreFactory + +class IosProtoBackupTest : AbstractProtoBackupTest() { + + private val helper = IosProtoTestHelper.standard("test_proto_backup") + private val extraTempDirs = mutableListOf() + + override val protoDatastore get() = helper.protoDatastore + override val testDispatcher: TestDispatcher get() = helper.testDispatcher + + override fun createProtoDatastore( + datastoreName: String, + migrations: List>, + ): GenericProtoDatastore { + val tempDir = NSTemporaryDirectory() + NSUUID().UUIDString + extraTempDirs += tempDir + return createProtoDatastoreFactory( + serializer = TestProtoDataSerializer, + defaultValue = TestProtoData(), + migrations = migrations, + producePath = { + "$tempDir/$datastoreName.pb" + }, + ) + } + + override fun deleteProtoDatastore(datastoreName: String) { + extraTempDirs.forEach { tempDir -> + NSFileManager.defaultManager.removeItemAtPath(tempDir, null) + } + extraTempDirs.clear() + } + + @BeforeTest + fun setup() = helper.setup() + + @AfterTest + fun tearDown() { + try { + helper.tearDown() + } finally { + deleteProtoDatastore("test_proto_backup_migration") + } + } +} diff --git a/generic-datastore-proto/src/jvmTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/DesktopProtoBackupTest.kt b/generic-datastore-proto/src/jvmTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/DesktopProtoBackupTest.kt new file mode 100644 index 00000000..9555a802 --- /dev/null +++ b/generic-datastore-proto/src/jvmTest/kotlin/io/github/arthurkun/generic/datastore/proto/backup/DesktopProtoBackupTest.kt @@ -0,0 +1,42 @@ +package io.github.arthurkun.generic.datastore.proto.backup + +import androidx.datastore.core.DataMigration +import io.github.arthurkun.generic.datastore.proto.GenericProtoDatastore +import io.github.arthurkun.generic.datastore.proto.core.DesktopProtoTestHelper +import io.github.arthurkun.generic.datastore.proto.core.TestProtoData +import io.github.arthurkun.generic.datastore.proto.core.TestProtoDataSerializer +import kotlinx.coroutines.test.TestDispatcher +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import io.github.arthurkun.generic.datastore.proto.createProtoDatastore as createProtoDatastoreFactory + +class DesktopProtoBackupTest : AbstractProtoBackupTest() { + + @TempDir + lateinit var tempFolder: File + + private val helper = DesktopProtoTestHelper.standard("test_proto_backup") + + override val protoDatastore get() = helper.protoDatastore + override val testDispatcher: TestDispatcher get() = helper.testDispatcher + + override fun createProtoDatastore( + datastoreName: String, + migrations: List>, + ): GenericProtoDatastore = createProtoDatastoreFactory( + serializer = TestProtoDataSerializer, + defaultValue = TestProtoData(), + migrations = migrations, + producePath = { + "${tempFolder.absolutePath}/$datastoreName.pb" + }, + ) + + @BeforeTest + fun setup() = helper.setup(tempFolder.absolutePath) + + @AfterTest + fun tearDown() = helper.tearDown() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 58f8e7a8..bd21baf5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ kotlinx-io = "0.9.0" okio = "3.17.0" wire = "6.4.1" +filekit = "0.14.2" spotless = "8.7.0" ktlint-core = "1.8.0" @@ -76,6 +77,7 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } ktlint-core = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint-core" } @@ -112,4 +114,3 @@ compose = [ "compose-material-icons-core", "compose-material-icons-extended" ] - diff --git a/samples/protoComposeApp/build.gradle.kts b/samples/protoComposeApp/build.gradle.kts index c6401351..615e3b46 100644 --- a/samples/protoComposeApp/build.gradle.kts +++ b/samples/protoComposeApp/build.gradle.kts @@ -17,6 +17,7 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.kotlinx.serialization.json) implementation(libs.wire.runtime) + implementation(libs.filekit.dialogs.compose) } } } @@ -37,6 +38,10 @@ compose.desktop { nativeDistributions { packageName = "generic-datastore-proto-sample" + + linux { + modules("jdk.security.auth") + } } } } diff --git a/samples/protoComposeApp/proguard-rules.pro b/samples/protoComposeApp/proguard-rules.pro index 3f0c32b2..c21988e4 100644 --- a/samples/protoComposeApp/proguard-rules.pro +++ b/samples/protoComposeApp/proguard-rules.pro @@ -4,3 +4,8 @@ -dontwarn android.os.Parcelable$Creator -dontwarn android.os.Parcel +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-keep class org.freedesktop.dbus.** { *; } +-keep class io.github.vinceglb.filekit.dialogs.platform.xdg.** { *; } +-keepattributes Signature,InnerClasses,RuntimeVisibleAnnotations diff --git a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2Screen.kt b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2Screen.kt index 211338d5..7ab587ef 100644 --- a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2Screen.kt +++ b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2Screen.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider @@ -29,6 +31,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import io.github.arthurkun.generic.datastore.proto.app.wire.UserSettings import io.github.arthurkun.generic.datastore.utils.collectAsStatePlatform +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher @Composable fun Proto2Screen( @@ -36,6 +42,25 @@ fun Proto2Screen( ) { val state by viewModel.uiState.collectAsStatePlatform() val apiCoverageStatus by viewModel.apiCoverageStatus.collectAsStatePlatform() + val backupStatus by viewModel.backupStatus.collectAsStatePlatform() + val backupSaver = rememberFileSaverLauncher( + dialogSettings = FileKitDialogSettings.createDefault(), + ) { destination -> + if (destination == null) { + viewModel.cancelBackupAction("Export") + } else { + viewModel.exportBackupTo(destination) + } + } + val backupPicker = rememberFilePickerLauncher( + type = FileKitType.File("pb"), + ) { source -> + if (source == null) { + viewModel.cancelBackupAction("Restore") + } else { + viewModel.restoreBackupFrom(source) + } + } LazyColumn( modifier = Modifier @@ -117,6 +142,22 @@ fun Proto2Screen( item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } + item { + BackupSection( + status = backupStatus, + onExport = { + backupSaver.launch( + suggestedName = "user_settings_backup", + defaultExtension = "pb", + allowedExtensions = setOf("pb"), + ) + }, + onRestore = backupPicker::launch, + ) + } + + item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } + item { ApiCoverageSection( status = apiCoverageStatus, @@ -126,6 +167,37 @@ fun Proto2Screen( } } +@Composable +private fun BackupSection( + status: String, + onExport: () -> Unit, + onRestore: () -> Unit, +) { + Column { + Text("Backup and Restore", style = MaterialTheme.typography.titleSmall) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onExport, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.FileDownload, contentDescription = null) + Text("Export", modifier = Modifier.padding(start = 4.dp)) + } + Button( + onClick = onRestore, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.FileUpload, contentDescription = null) + Text("Restore", modifier = Modifier.padding(start = 4.dp)) + } + } + Text(status, style = MaterialTheme.typography.bodySmall) + } +} + @Composable private fun UsernameSection( username: String, diff --git a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2ViewModel.kt b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2ViewModel.kt index 973af31e..9c723b63 100644 --- a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2ViewModel.kt +++ b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto2/Proto2ViewModel.kt @@ -13,6 +13,9 @@ import io.github.arthurkun.generic.datastore.proto.kserializedListField import io.github.arthurkun.generic.datastore.proto.kserializedSetField import io.github.arthurkun.generic.datastore.proto.nullableKserializedField import io.github.arthurkun.generic.datastore.proto.nullableKserializedListField +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import io.github.vinceglb.filekit.write import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -43,6 +46,8 @@ class Proto2ViewModel( private val jsonConfig = Json { ignoreUnknownKeys = true } private val _apiCoverageStatus = MutableStateFlow("Not run") val apiCoverageStatus: StateFlow = _apiCoverageStatus.asStateFlow() + private val _backupStatus = MutableStateFlow("Ready") + val backupStatus: StateFlow = _backupStatus.asStateFlow() /** Whole-object preference via `data()` */ val wholeData: ProtoPreference = datastore.data() @@ -228,6 +233,40 @@ class Proto2ViewModel( usernamePref.resetToDefaultBlocking() } + fun exportBackupTo(destination: PlatformFile) { + viewModelScope.launch { + _backupStatus.value = "Exporting" + try { + val bytes = datastore.exportAsByteArray() + destination.write(bytes) + _backupStatus.value = "Exported ${bytes.size} bytes" + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + _backupStatus.value = "Export failed: ${failure.message ?: "Unknown error"}" + } + } + } + + fun restoreBackupFrom(source: PlatformFile) { + viewModelScope.launch { + _backupStatus.value = "Restoring" + try { + val bytes = source.readBytes() + datastore.importFromByteArray(bytes) + _backupStatus.value = "Restored ${bytes.size} bytes" + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + _backupStatus.value = "Restore failed: ${failure.message ?: "Unknown error"}" + } + } + } + + fun cancelBackupAction(action: String) { + _backupStatus.value = "$action cancelled" + } + fun runApiCoverageShowcase() { viewModelScope.launch { _apiCoverageStatus.value = "Running" diff --git a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3Screen.kt b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3Screen.kt index 55a6ebaf..22e8588c 100644 --- a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3Screen.kt +++ b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3Screen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider @@ -26,12 +28,35 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import io.github.arthurkun.generic.datastore.utils.collectAsStatePlatform +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher @Composable fun Proto3Screen( viewModel: Proto3ViewModel, ) { val state by viewModel.uiState.collectAsStatePlatform() + val backupStatus by viewModel.backupStatus.collectAsStatePlatform() + val backupSaver = rememberFileSaverLauncher( + dialogSettings = FileKitDialogSettings.createDefault(), + ) { destination -> + if (destination == null) { + viewModel.cancelBackupAction("Export") + } else { + viewModel.exportBackupTo(destination) + } + } + val backupPicker = rememberFilePickerLauncher( + type = FileKitType.File("pb"), + ) { source -> + if (source == null) { + viewModel.cancelBackupAction("Restore") + } else { + viewModel.restoreBackupFrom(source) + } + } LazyColumn( modifier = Modifier @@ -156,6 +181,53 @@ fun Proto3Screen( onResetNetwork = viewModel::resetNetwork, ) } + + item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } + + item { + BackupSection( + status = backupStatus, + onExport = { + backupSaver.launch( + suggestedName = "app_config_backup", + defaultExtension = "pb", + allowedExtensions = setOf("pb"), + ) + }, + onRestore = backupPicker::launch, + ) + } + } +} + +@Composable +private fun BackupSection( + status: String, + onExport: () -> Unit, + onRestore: () -> Unit, +) { + Column { + Text("Backup and Restore", style = MaterialTheme.typography.titleSmall) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onExport, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.FileDownload, contentDescription = null) + Text("Export", modifier = Modifier.padding(start = 4.dp)) + } + Button( + onClick = onRestore, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.FileUpload, contentDescription = null) + Text("Restore", modifier = Modifier.padding(start = 4.dp)) + } + } + Text(status, style = MaterialTheme.typography.bodySmall) } } diff --git a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3ViewModel.kt b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3ViewModel.kt index 1289fede..ec76fb11 100644 --- a/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3ViewModel.kt +++ b/samples/protoComposeApp/src/commonMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/ui/proto3/Proto3ViewModel.kt @@ -5,11 +5,17 @@ import androidx.lifecycle.viewModelScope import io.github.arthurkun.generic.datastore.proto.ProtoDatastore import io.github.arthurkun.generic.datastore.proto.ProtoPreference import io.github.arthurkun.generic.datastore.proto.app.wire.AppConfig +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import io.github.vinceglb.filekit.write +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException /** * ViewModel for the Proto3 (AppConfig) screen. @@ -21,6 +27,9 @@ class Proto3ViewModel( private val datastore: ProtoDatastore, ) : ViewModel() { + private val _backupStatus = MutableStateFlow("Ready") + val backupStatus: StateFlow = _backupStatus.asStateFlow() + /** Whole-object preference */ val wholeData: ProtoPreference = datastore.data() @@ -238,6 +247,40 @@ class Proto3ViewModel( fun resetAppNameBlocking() { appNamePref.resetToDefaultBlocking() } + + fun exportBackupTo(destination: PlatformFile) { + viewModelScope.launch { + _backupStatus.value = "Exporting" + try { + val bytes = datastore.exportAsByteArray() + destination.write(bytes) + _backupStatus.value = "Exported ${bytes.size} bytes" + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + _backupStatus.value = "Export failed: ${failure.message ?: "Unknown error"}" + } + } + } + + fun restoreBackupFrom(source: PlatformFile) { + viewModelScope.launch { + _backupStatus.value = "Restoring" + try { + val bytes = source.readBytes() + datastore.importFromByteArray(bytes) + _backupStatus.value = "Restored ${bytes.size} bytes" + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + _backupStatus.value = "Restore failed: ${failure.message ?: "Unknown error"}" + } + } + } + + fun cancelBackupAction(action: String) { + _backupStatus.value = "$action cancelled" + } } /** Helper for combining more than 5 flows */ diff --git a/samples/protoComposeApp/src/desktopMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/Main.kt b/samples/protoComposeApp/src/desktopMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/Main.kt index b58ccab8..174cd787 100644 --- a/samples/protoComposeApp/src/desktopMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/Main.kt +++ b/samples/protoComposeApp/src/desktopMain/kotlin/io/github/arthurkun/generic/datastore/proto/app/Main.kt @@ -7,8 +7,10 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import io.github.arthurkun.generic.datastore.proto.app.domain.AppContainer import io.github.arthurkun.generic.datastore.proto.app.theme.ProtoAppTheme +import io.github.vinceglb.filekit.FileKit fun main() { + FileKit.init(appId = "generic-datastore-proto-sample") val appContainer = AppContainer() application {