From 6796e868c602932b5a8b1ce200a0f2d5fb6dc4ff Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 31 Oct 2023 16:49:11 +0800 Subject: [PATCH] Updated on 2026-08-14 --- core/datasource/build.gradle.kts | 4 +- .../di/AppPreferencesStoreModule.kt | 31 ++++++ .../local/preferences/AppPreferencesStore.kt | 45 ++++++++ .../local/preferences/PreferencesDataStore.kt | 54 +++++++++ .../local/preferences/PreferencesKeys.kt | 13 +++ .../utils/AppPreferencesStoreExt.kt | 46 ++++++++ .../utils/PreferencesDataStoreExt.kt | 33 ++++++ .../utils/SharedPreferencesKeyMigration.kt | 104 ++++++++++++++++++ gradle/dependencies.toml | 2 + 9 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/PreferencesDataStoreExt.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 88d82bd624..137ccdb922 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -48,10 +48,12 @@ dependencies { /** Security */ implementation(deps.spongecastle.core) - /** Chucker */ /** Chucker */ debugImplementation(deps.chucker) externalImplementation(deps.chuckerStub) internalImplementation(deps.chuckerStub) releaseImplementation(deps.chuckerStub) + + /** Local storages */ + implementation(deps.androidx.datastore) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt new file mode 100644 index 0000000000..e36b36096b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt @@ -0,0 +1,31 @@ +package com.tangem.datasource.di + +import android.content.Context +import com.squareup.moshi.Moshi +import com.tangem.datasource.local.* +import com.tangem.datasource.local.preferences.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AppPreferencesStoreModule { + + @Provides + @Singleton + fun provideAppPreferencesStore( + @ApplicationContext appContext: Context, + dispatchers: CoroutineDispatcherProvider, + @NetworkMoshi moshi: Moshi, + ): AppPreferencesStore { + return AppPreferencesStore( + preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), + moshi = moshi, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt new file mode 100644 index 0000000000..7e401b9333 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt @@ -0,0 +1,45 @@ +package com.tangem.datasource.local.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.squareup.moshi.Moshi + +/** + * Application preferences store. + * AppPreferencesStore is wrapper around DataStore that supports json serialization and deserialization. + * + * @property moshi Moshi instance. Property has 'public' modifier because it is used + * by Public-API inline function. Don't use it directly. + * @property preferencesDataStore DataStore instance + * +[REDACTED_AUTHOR] + */ +class AppPreferencesStore( + val moshi: Moshi, + private val preferencesDataStore: DataStore, +) : DataStore by preferencesDataStore { + + /** + * Edit data according with transaction [transform]. + * + * @param transform transaction. It has receiver [AppPreferencesStore] that allows to use [getObject], [setObject] + * functions when creating transaction. + */ + suspend fun editData(transform: suspend AppPreferencesStore.(MutablePreferences) -> Unit): Preferences { + return edit { transform(it) } + } + + /** Get nullable data [T] by string [key] from [MutablePreferences] */ + inline fun MutablePreferences.getObject(key: Preferences.Key): T? { + val adapter = moshi.adapter(T::class.java) + return this[key]?.let(adapter::fromJson) + } + + /** Set data [T] by string [key] to [MutablePreferences] */ + inline fun MutablePreferences.setObject(key: Preferences.Key, value: T) { + val adapter = moshi.adapter(T::class.java) + this[key] = adapter.toJson(value) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt new file mode 100644 index 0000000000..7d0d6ba94a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt @@ -0,0 +1,54 @@ +package com.tangem.datasource.local.preferences + +import android.content.Context +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.tangem.datasource.local.preferences.PreferencesDataStore.INSTANCE +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + +/** + * Application preferences data store 'DataStore'. + * Implements the singleton pattern [INSTANCE] under the hood. + * +[REDACTED_AUTHOR] + */ +internal object PreferencesDataStore { + + private const val PREFERENCES_FILE_NAME = "TAP_PREFS" + + private var INSTANCE: DataStore? = null + + fun getInstance(context: Context, dispatcher: CoroutineContext): DataStore { + return INSTANCE ?: create(context, dispatcher).also { INSTANCE = it } + } + + private fun create(context: Context, dispatcher: CoroutineContext): DataStore { + return PreferenceDataStoreFactory.create( + corruptionHandler = createCorruptionHandler(), + migrations = createMigrations(), + scope = CoroutineScope(context = dispatcher + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile(name = PREFERENCES_FILE_NAME) }, + ) + } + + private fun createCorruptionHandler(): ReplaceFileCorruptionHandler { + return ReplaceFileCorruptionHandler( + produceNewData = { + Timber.w(it) + emptyPreferences() + }, + ) + } + + private fun createMigrations(): List> { + return listOf() + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt new file mode 100644 index 0000000000..90f2b61270 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.local.preferences + +/** + * All preferences keys that DataStore is stored. + * +[REDACTED_AUTHOR] + */ +object PreferencesKeys + +/** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore */ +internal fun getTapPrefKeysToMigrate(): Set { + return setOf() +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt new file mode 100644 index 0000000000..1ca1355871 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.local.preferences.utils + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.tangem.datasource.local.preferences.AppPreferencesStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +/** Get flow of nullable data [T] by string [key] */ +inline fun AppPreferencesStore.getObject(key: Preferences.Key): Flow { + val adapter = moshi.adapter(T::class.java) + return data.map { it[key]?.let(adapter::fromJson) } +} + +/** Get flow of data [T] by string [key]. If data is not found, it returns [default] */ +inline fun AppPreferencesStore.getObject(key: Preferences.Key, default: T): Flow { + val adapter = moshi.adapter(T::class.java) + return data.map { it[key]?.let(adapter::fromJson) ?: default } +} + +/** Get nullable data [T] by string [key] */ +suspend inline fun AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? { + val adapter = moshi.adapter(T::class.java) + return data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) +} + +/** Get data [T] by string [key]. If data is not found, it returns [default] */ +suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( + key: Preferences.Key, + default: T, +): T { + val adapter = moshi.adapter(T::class.java) + return data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + ?: default +} + +/** Store data [value] by string [key] */ +suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T) { + val adapter = moshi.adapter(T::class.java) + edit { it[key] = adapter.toJson(value) } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/PreferencesDataStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/PreferencesDataStoreExt.kt new file mode 100644 index 0000000000..e9177bad53 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/PreferencesDataStoreExt.kt @@ -0,0 +1,33 @@ +package com.tangem.datasource.local.preferences.utils + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +/** Get flow of nullable data [T] by [key] */ +fun DataStore.get(key: Preferences.Key): Flow { + return data.map { it[key] } +} + +/** Get flow of data [T] by [key]. If data is not found, it returns [default] */ +fun DataStore.get(key: Preferences.Key, default: T): Flow { + return data.map { it[key] ?: default } +} + +/** Get nullable data [T] by [key] */ +suspend fun DataStore.getSyncOrNull(key: Preferences.Key): T? { + return data.firstOrNull()?.get(key) +} + +/** Get data [T] by [key]. If data is not found, it returns [default] */ +suspend fun DataStore.getSyncOrDefault(key: Preferences.Key, default: T): T { + return data.firstOrNull()?.get(key) ?: default +} + +/** Store data [value] by [key] */ +suspend fun DataStore.store(key: Preferences.Key, value: T) { + edit { it[key] = value } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt new file mode 100644 index 0000000000..f9c11e6471 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt @@ -0,0 +1,104 @@ +package com.tangem.datasource.local.preferences.utils + +import android.content.Context +import android.os.Build +import androidx.annotation.DoNotInline +import androidx.annotation.RequiresApi +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.* +import java.io.File +import java.io.IOException + +/** + * Migration of a specified key with name changing. + * Example, migrate the "key1" from "pref1" to the "key2" from "pref2". + * + * @property context context + * @property legacyPrefsName legacy SharedPreferences name + * @property legacyKeyName legacy SharedPreferences key name + * @property keyName new SharedPreferences key name + */ +internal class SharedPreferencesKeyMigration( + private val context: Context, + private val legacyPrefsName: String, + private val legacyKeyName: String, + private val keyName: String, +) : DataMigration { + + private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + + override suspend fun cleanUp() { + val sharedPrefsEditor = legacyPrefs.edit() + + sharedPrefsEditor.remove(legacyKeyName) + + if (!sharedPrefsEditor.commit()) { + throw IOException("Unable to delete migrated keys from SharedPreferences.") + } + + if (legacyPrefs.all.isEmpty()) { + deleteSharedPreferences(context = context, name = legacyPrefsName) + } + } + + override suspend fun shouldMigrate(currentData: Preferences): Boolean = true + + override suspend fun migrate(currentData: Preferences): Preferences { + val currentKeys = currentData.asMap().keys.map(Preferences.Key<*>::name) + + // If migration is already happened, return + if (currentKeys.contains(keyName)) return currentData + + val value = legacyPrefs.all[legacyKeyName] + if (value != null) { + val mutablePreferences = currentData.toMutablePreferences() + + when (value) { + is Boolean -> mutablePreferences[booleanPreferencesKey(keyName)] = value + is Float -> mutablePreferences[floatPreferencesKey(keyName)] = value + is Int -> mutablePreferences[intPreferencesKey(keyName)] = value + is Long -> mutablePreferences[longPreferencesKey(keyName)] = value + is String -> mutablePreferences[stringPreferencesKey(keyName)] = value + is Set<*> -> { + @Suppress("UNCHECKED_CAST") + mutablePreferences[stringSetPreferencesKey(keyName)] = value as Set + } + } + + return mutablePreferences.toPreferences() + } + + return currentData + } + + private fun deleteSharedPreferences(context: Context, name: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!Api24Impl.deleteSharedPreferences(context, name)) { + throw IOException("Unable to delete SharedPreferences: $name") + } + } else { + val prefsFile = getSharedPrefsFile(context, name) + val prefsBackup = getSharedPrefsBackup(prefsFile) + + prefsFile.delete() + prefsBackup.delete() + } + } + + @RequiresApi(Build.VERSION_CODES.N) + private object Api24Impl { + + @JvmStatic + @DoNotInline + fun deleteSharedPreferences(context: Context, name: String): Boolean { + return context.deleteSharedPreferences(name) + } + } + + private fun getSharedPrefsFile(context: Context, name: String): File { + val prefsDir = File(context.applicationInfo.dataDir, "shared_prefs") + return File(prefsDir, "$name.xml") + } + + private fun getSharedPrefsBackup(prefsFile: File) = File(prefsFile.path + ".bak") +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 02c47f7668..ba18cd9795 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -20,6 +20,7 @@ androidxFragment = "1.5.3" androidxLifecycle = "2.5.1" androidx-paging = "3.1.1" androidx-palette = "1.0.0" +androidx-datastore = "1.0.0" # endregion AndroidX # region Compose @@ -136,6 +137,7 @@ lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", v lifecycle-viewModel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compoese-lifecycle-runtime" } androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } # region AndroidX # region Compose