Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-09 15:03:19 +05:00
parent ea780f563e
commit 42c8be234e
18 changed files with 275 additions and 193 deletions

View file

@ -1,6 +1,9 @@
package com.tangem.tap package com.tangem.tap
import android.app.Application import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import coil.ImageLoader import coil.ImageLoader
@ -71,9 +74,7 @@ import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints import dagger.hilt.EntryPoints
import kotlinx.coroutines.async import kotlinx.coroutines.*
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import org.rekotlin.Store import org.rekotlin.Store
import kotlin.collections.set import kotlin.collections.set
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository
@ -228,12 +229,32 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
// endregion // endregion
private val appScope = MainScope()
override fun onCreate() { override fun onCreate() {
enableStrictModeInDebug()
super.onCreate() super.onCreate()
init() init()
}
updateLogFiles() private fun enableStrictModeInDebug() {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.build(),
)
}
} }
private fun updateLogFiles() { private fun updateLogFiles() {
@ -260,18 +281,31 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
foregroundActivityObserver = ForegroundActivityObserver() foregroundActivityObserver = ForegroundActivityObserver()
registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks)
// TODO: Try to performance and user experience. // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them.
// [REDACTED_JIRA]
runBlocking { runBlocking {
awaitAll( awaitAll(
async { featureTogglesManager.init() }, async {
async { excludedBlockchainsManager.init() }, featureTogglesManager.init()
async { initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) }, },
async {
excludedBlockchainsManager.init()
},
) )
} }
loadNativeLibraries() appScope.launch {
initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize())
launch(Dispatchers.IO) {
loadNativeLibraries()
walletConnect2Repository.init(
projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId,
)
updateLogFiles()
}
}
ExceptionHandler.append(blockchainExceptionHandler) ExceptionHandler.append(blockchainExceptionHandler)
if (LogConfig.network.blockchainSdkNetwork) { if (LogConfig.network.blockchainSdkNetwork) {
BlockchainSdkRetrofitBuilder.interceptors = listOf( BlockchainSdkRetrofitBuilder.interceptors = listOf(
createNetworkLoggingInterceptor(), createNetworkLoggingInterceptor(),
@ -287,9 +321,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
appStateHolder.mainStore = store
walletConnect2Repository.init(projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId) appStateHolder.mainStore = store
} }
private fun createReduxStore(): Store<AppState> { private fun createReduxStore(): Store<AppState> {

View file

@ -20,18 +20,22 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor(
private val dispatcherProvider: CoroutineDispatcherProvider, private val dispatcherProvider: CoroutineDispatcherProvider,
) : VisaAuthTokenStorage { ) : VisaAuthTokenStorage {
private val secureStorage = AndroidSecureStorage( private val secureStorage by lazy {
preferences = SecureStorage.createEncryptedSharedPreferences( AndroidSecureStorage(
context = applicationContext, preferences = SecureStorage.createEncryptedSharedPreferences(
storageName = "visa_auth_storage", context = applicationContext,
), storageName = "visa_auth_storage",
) ),
)
}
private val moshi = Moshi.Builder() private val moshi by lazy {
.add(KotlinJsonAdapterFactory()) Moshi.Builder()
.build() .add(KotlinJsonAdapterFactory())
.build()
}
private val tokensAdapter = moshi.adapter(VisaAuthTokens::class.java) private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) }
override suspend fun store(cardId: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) { override suspend fun store(cardId: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens) val json = tokensAdapter.toJson(tokens)

View file

@ -20,12 +20,14 @@ class DefaultVisaOTPStorage @Inject constructor(
private val dispatcherProvider: CoroutineDispatcherProvider, private val dispatcherProvider: CoroutineDispatcherProvider,
) : VisaOTPStorage { ) : VisaOTPStorage {
private val secureStorage = AndroidSecureStorage( private val secureStorage by lazy {
preferences = SecureStorage.createEncryptedSharedPreferences( AndroidSecureStorage(
context = applicationContext, preferences = SecureStorage.createEncryptedSharedPreferences(
storageName = "visa_otp_storage", context = applicationContext,
), storageName = "visa_otp_storage",
) ),
)
}
override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) { override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) {
secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId) secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId)

View file

@ -20,9 +20,12 @@ internal class DefaultUserWalletsPublicInformationRepository(
moshi: Moshi, moshi: Moshi,
private val secureStorage: SecureStorage, private val secureStorage: SecureStorage,
) : UserWalletsPublicInformationRepository { ) : UserWalletsPublicInformationRepository {
private val publicInformationAdapter: JsonAdapter<List<UserWalletPublicInformation>> = moshi.adapter(
Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), private val publicInformationAdapter: JsonAdapter<List<UserWalletPublicInformation>> by lazy {
) moshi.adapter(
Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java),
)
}
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> { override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {

View file

@ -24,12 +24,15 @@ internal class DefaultUserWalletsSensitiveInformationRepository(
private val secureStorage: SecureStorage, private val secureStorage: SecureStorage,
) : UserWalletsSensitiveInformationRepository { ) : UserWalletsSensitiveInformationRepository {
private val sensitiveInformationAdapter: JsonAdapter<UserWalletSensitiveInformation> = moshi.adapter( private val sensitiveInformationAdapter: JsonAdapter<UserWalletSensitiveInformation> by lazy {
UserWalletSensitiveInformation::class.java, moshi.adapter(UserWalletSensitiveInformation::class.java)
) }
private val encryptedSensitiveInformationMapAdapter: JsonAdapter<Map<String, ByteArray>> = moshi.adapter(
Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), private val encryptedSensitiveInformationMapAdapter: JsonAdapter<Map<String, ByteArray>> by lazy {
) moshi.adapter(
Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java),
)
}
override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult<Unit> { override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult<Unit> {
if (encryptionKey == null) { if (encryptionKey == null) {

View file

@ -32,7 +32,7 @@ internal class DevFeatureTogglesManager(
val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>( val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>(
key = PreferencesKeys.FEATURE_TOGGLES_KEY, key = PreferencesKeys.FEATURE_TOGGLES_KEY,
) ?: emptyMap() ) ?: emptyMap<String, Boolean>()
val localFeatureToggles = localTogglesStorage.toggles val localFeatureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty()) .associateToggles(currentVersion = versionProvider.get().orEmpty())

View file

@ -2,6 +2,7 @@ package com.tangem.core.configtoggle.manager
import android.annotation.SuppressLint import android.annotation.SuppressLint
import com.google.common.truth.Truth import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.storage.ConfigToggle import com.tangem.core.configtoggle.storage.ConfigToggle
@ -12,6 +13,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -24,7 +26,11 @@ import kotlin.collections.set
internal class DevTogglesManagerTest { internal class DevTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>() private val localTogglesStorage = mockk<TogglesStorage>()
private val appPreferenceStore = mockk<AppPreferencesStore>(relaxed = true) private val appPreferenceStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = mockk(relaxed = true),
)
private val versionProvider = mockk<VersionProvider>() private val versionProvider = mockk<VersionProvider>()
private val manager = DevFeatureTogglesManager( private val manager = DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage, localTogglesStorage = localTogglesStorage,

View file

@ -8,6 +8,7 @@ import com.squareup.moshi.Types
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -24,7 +25,11 @@ internal class LocalTogglesStorageTest {
private val jsonAdapter = mockk<JsonAdapter<List<ConfigToggle>>>() private val jsonAdapter = mockk<JsonAdapter<List<ConfigToggle>>>()
// Impossible to mockk AssetLoader because it implement inline functions // Impossible to mockk AssetLoader because it implement inline functions
private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val storage = LocalTogglesStorage(assetLoader) private val storage = LocalTogglesStorage(assetLoader)

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.squareup.moshi.adapter import com.squareup.moshi.adapter
import com.tangem.utils.coroutines.runCatching
import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -23,69 +25,63 @@ import javax.inject.Singleton
class AssetLoader @Inject constructor( class AssetLoader @Inject constructor(
val assetReader: AssetReader, val assetReader: AssetReader,
@NetworkMoshi val moshi: Moshi, @NetworkMoshi val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
) { ) {
/** Load content [Content] of asset file [fileName] */ /** Load content [Content] of asset file [fileName] */
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
suspend inline fun <reified Content> load(fileName: String): Content? { suspend inline fun <reified Content> load(fileName: String): Content? = runCatching(dispatchers.io) {
return runCatching { val json = assetReader.read(fullFileName = "$fileName.json")
val json = assetReader.read(fullFileName = "$fileName.json")
moshi.adapter<Content>().fromJson(json) moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
null
},
)
} }
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
null
},
)
/** Load list [V] values of asset file [fileName] */ /** Load list [V] values of asset file [fileName] */
suspend inline fun <reified V> loadList(fileName: String): List<V> { suspend inline fun <reified V> loadList(fileName: String): List<V> = runCatching(dispatchers.io) {
return runCatching { val json = assetReader.read(fullFileName = "$fileName.json")
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(List::class.java, V::class.java) val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type) val adapter = moshi.adapter<List<V>>(type)
adapter.fromJson(json) adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyList()
},
)
} }
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyList()
},
)
/** Load map [String] keys and [V] values of asset file [fileName] */ /** Load map [String] keys and [V] values of asset file [fileName] */
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> { suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> = runCatching(dispatchers.io) {
return runCatching { val json = assetReader.read(fullFileName = "$fileName.json")
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type) val adapter = moshi.adapter<Map<String, V>>(type)
adapter.fromJson(json) adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyMap()
},
)
} }
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyMap()
},
)
} }

View file

@ -1,23 +1,19 @@
package com.tangem.datasource.asset.reader package com.tangem.datasource.asset.reader
import android.content.res.AssetManager import android.content.res.AssetManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.io.BufferedReader import java.io.BufferedReader
/** /**
* Implementation of asset file reader * Implementation of asset file reader
* *
* @property assetManager asset manager * @property assetManager asset manager
* @property dispatchers dispatchers
*/ */
internal class AndroidAssetReader( internal class AndroidAssetReader(
private val assetManager: AssetManager, private val assetManager: AssetManager,
private val dispatchers: CoroutineDispatcherProvider,
) : AssetReader { ) : AssetReader {
override suspend fun read(fullFileName: String): String = withContext(dispatchers.io) { override suspend fun read(fullFileName: String): String {
assetManager.open(fullFileName).bufferedReader() return assetManager.open(fullFileName).bufferedReader()
.use(BufferedReader::readText) .use(BufferedReader::readText)
} }
} }

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.di
import android.content.Context import android.content.Context
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.datasource.local.*
import com.tangem.datasource.local.preferences.* import com.tangem.datasource.local.preferences.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule {
return AppPreferencesStore( return AppPreferencesStore(
preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io),
moshi = moshi, moshi = moshi,
dispatchers = dispatchers,
) )
} }
} }

View file

@ -3,7 +3,6 @@ package com.tangem.datasource.di
import android.content.Context import android.content.Context
import com.tangem.datasource.asset.reader.AndroidAssetReader import com.tangem.datasource.asset.reader.AndroidAssetReader
import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -17,10 +16,7 @@ internal object AssetReaderModule {
@Singleton @Singleton
@Provides @Provides
fun providesAsserReader( fun providesAsserReader(@ApplicationContext context: Context): AssetReader {
@ApplicationContext context: Context, return AndroidAssetReader(context.assets)
dispatchers: CoroutineDispatcherProvider,
): AssetReader {
return AndroidAssetReader(context.assets, dispatchers)
} }
} }

View file

@ -37,8 +37,12 @@ class AppLogsStore @Inject constructor(
private val mutex = Mutex() private val mutex = Mutex()
private val zipMutex = Mutex() private val zipMutex = Mutex()
private val file = File(applicationContext.filesDir, PERMITTED_FILE_NAME) private val logFile by lazy {
private val fileZip = File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP) File(applicationContext.filesDir, PERMITTED_FILE_NAME)
}
private val logFileZip by lazy {
File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP)
}
private val formatter = DateTimeFormatterBuilder() private val formatter = DateTimeFormatterBuilder()
.appendDayOfMonth(2) .appendDayOfMonth(2)
@ -55,12 +59,12 @@ class AppLogsStore @Inject constructor(
.toFormatter() .toFormatter()
/** Get log file */ /** Get log file */
fun getFile(): File? = if (file.exists()) file else null fun getFile(): File? = if (logFile.exists()) logFile else null
suspend fun getZipFile(): File? { suspend fun getZipFile(): File? {
return zipMutex.withLock { return zipMutex.withLock {
if (file.exists()) { if (logFile.exists()) {
zip(listOf(file), fileZip) zip(listOf(logFile), logFileZip)
} else { } else {
null null
} }
@ -98,8 +102,8 @@ class AppLogsStore @Inject constructor(
/** Delete deprecated logs if file size exceeds [maxSize] */ /** Delete deprecated logs if file size exceeds [maxSize] */
fun deleteDeprecatedLogs(maxSize: Int) { fun deleteDeprecatedLogs(maxSize: Int) {
launchWithLock { launchWithLock {
if (file.exists() && file.length() > maxSize) { if (logFile.exists() && logFile.length() > maxSize) {
file.delete() logFile.delete()
} }
} }
} }
@ -117,7 +121,7 @@ class AppLogsStore @Inject constructor(
} }
private fun writeMessage(tag: String, vararg messages: String) { private fun writeMessage(tag: String, vararg messages: String) {
BufferedWriter(FileWriter(file, true)).use { writer -> BufferedWriter(FileWriter(logFile, true)).use { writer ->
writer.append(formatter.print(DateTime.now())) writer.append(formatter.print(DateTime.now()))
writer.append(": $tag ") writer.append(": $tag ")
messages.forEach(writer::append) messages.forEach(writer::append)
@ -126,8 +130,8 @@ class AppLogsStore @Inject constructor(
} }
private fun createFileIfNotExist() { private fun createFileIfNotExist() {
if (!file.exists()) { if (!logFile.exists()) {
runCatching { file.createNewFile() } runCatching { logFile.createNewFile() }
.onFailure(Timber::e) .onFailure(Timber::e)
} }
} }

View file

@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/** /**
* Application preferences store. * Application preferences store.
@ -19,6 +20,7 @@ import com.squareup.moshi.Types
*/ */
class AppPreferencesStore( class AppPreferencesStore(
val moshi: Moshi, val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
private val preferencesDataStore: DataStore<Preferences>, private val preferencesDataStore: DataStore<Preferences>,
) : DataStore<Preferences> by preferencesDataStore { ) : DataStore<Preferences> by preferencesDataStore {

View file

@ -5,23 +5,25 @@ import androidx.datastore.preferences.core.edit
import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
/** Get flow of nullable data [T] by string [key] */ /** Get flow of nullable data [T] by string [key] */
inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>): Flow<T?> { inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>): Flow<T?> {
val adapter = moshi.adapter(T::class.java) return flow {
return data.map { preferences -> val adapter = moshi.adapter(T::class.java)
preferences[key]?.let { emitAll(
try { data.map { preferences ->
adapter.fromJson(it) preferences[key]?.let {
} catch (e: JsonDataException) { try {
null adapter.fromJson(it)
} } catch (e: JsonDataException) {
} null
}.distinctUntilChanged() }
}
}.distinctUntilChanged(),
)
}
} }
/** /**
@ -32,16 +34,19 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
* @see getObjectList * @see getObjectList
* */ * */
inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>, default: T): Flow<T> { inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>, default: T): Flow<T> {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types return flow {
return data.map { val adapter = moshi.adapter(T::class.java)
try { emitAll(
it[key]?.let(adapter::fromJson) ?: default data.map {
} catch (e: JsonDataException) { try {
default it[key]?.let(adapter::fromJson) ?: default
} } catch (e: JsonDataException) {
}.distinctUntilChanged() default
}
}.distinctUntilChanged(),
)
}
} }
/** /**
* Get nullable data [T] by string [key] * Get nullable data [T] by string [key]
* *
@ -49,26 +54,27 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
* *
* @see getObjectListSync * @see getObjectListSync
* */ * */
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key<String>): T? { suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key<String>): T? =
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types withContext(dispatchers.io) {
return data.firstOrNull() val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
?.get(key) data.firstOrNull()
?.let { ?.get(key)
try { ?.let {
adapter.fromJson(it) try {
} catch (e: JsonDataException) { adapter.fromJson(it)
null } catch (e: JsonDataException) {
null
}
} }
} }
}
/** Get data [T] by string [key]. If data is not found, it returns [default] */ /** Get data [T] by string [key]. If data is not found, it returns [default] */
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault( suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
key: Preferences.Key<String>, key: Preferences.Key<String>,
default: T, default: T,
): T { ): T = withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java) val adapter = moshi.adapter(T::class.java)
return data.firstOrNull() data.firstOrNull()
?.get(key) ?.get(key)
?.let { ?.let {
try { try {
@ -87,37 +93,47 @@ suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
* *
* @see storeObjectList * @see storeObjectList
* */ * */
suspend inline fun <reified T> AppPreferencesStore.storeObject(key: Preferences.Key<String>, value: T) { @Suppress("OptionalUnit")
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types suspend inline fun <reified T> AppPreferencesStore.storeObject(key: Preferences.Key<String>, value: T): Unit =
edit { it[key] = adapter.toJson(value) } withContext(dispatchers.io) {
} val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
edit { it[key] = adapter.toJson(value) }
}
/** Store list of data [value] by string [key] */ /** Store list of data [value] by string [key] */
suspend inline fun <reified T> AppPreferencesStore.storeObjectList(key: Preferences.Key<String>, value: List<T>) { suspend inline fun <reified T> AppPreferencesStore.storeObjectList(key: Preferences.Key<String>, value: List<T>) =
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java)) withContext(dispatchers.io) {
edit { it[key] = adapter.toJson(value) } val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
} edit { it[key] = adapter.toJson(value) }
}
/** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */ /** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */
inline fun <reified T> AppPreferencesStore.getObjectList(key: Preferences.Key<String>): Flow<List<T>?> { inline fun <reified T> AppPreferencesStore.getObjectList(key: Preferences.Key<String>): Flow<List<T>?> {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java)) return flow {
return data.map { it[key]?.let(adapter::fromJson) }.distinctUntilChanged() val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
emitAll(
data.map {
it[key]?.let(adapter::fromJson)
}.distinctUntilChanged(),
)
}
} }
/** Get list of data [T] by string [key], or empty if data is not found */ /** Get list of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> { suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> =
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java)) withContext(dispatchers.io) {
return data.firstOrNull() val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
?.get(key) data.firstOrNull()
?.let(adapter::fromJson) ?.get(key)
.orEmpty() ?.let(adapter::fromJson)
} .orEmpty()
}
/** Store map with [String] key and value [V] by string [key] */ /** Store map with [String] key and value [V] by string [key] */
suspend inline fun <reified V> AppPreferencesStore.storeObjectMap( suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
key: Preferences.Key<String>, key: Preferences.Key<String>,
value: Map<String, V>, value: Map<String, V>,
) { ) = withContext(dispatchers.io) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type) val adapter = moshi.adapter<Map<String, V>>(type)
@ -125,37 +141,47 @@ suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
} }
/** Get map with [String] key and value [V] by string [key], or empty if data is not found */ /** Get map with [String] key and value [V] by string [key], or empty if data is not found */
suspend inline fun <reified V> AppPreferencesStore.getObjectMapSync(key: Preferences.Key<String>): Map<String, V> { suspend inline fun <reified V> AppPreferencesStore.getObjectMapSync(key: Preferences.Key<String>): Map<String, V> =
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) withContext(dispatchers.io) {
val adapter = moshi.adapter<Map<String, V>>(type) val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.firstOrNull() data.firstOrNull()
?.get(key) ?.get(key)
?.let(adapter::fromJson) ?.let(adapter::fromJson)
.orEmpty() .orEmpty()
} }
/** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */ /** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */
inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences.Key<String>): Flow<Map<String, V>> { inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences.Key<String>): Flow<Map<String, V>> {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) return flow {
val adapter = moshi.adapter<Map<String, V>>(type) val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() } emitAll(
data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() },
)
}
} }
/** Get set of data [T] by string [key], or empty if data is not found */ /** Get set of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> { suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> =
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java)) withContext(dispatchers.io) {
return data.firstOrNull() val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
?.get(key) data.firstOrNull()
?.let(adapter::fromJson) ?.get(key)
.orEmpty() ?.let(adapter::fromJson)
} .orEmpty()
}
/** Get flow of set of [T] by string [key], or empty if data is not found */ /** Get flow of set of [T] by string [key], or empty if data is not found */
inline fun <reified T> AppPreferencesStore.getObjectSet(key: Preferences.Key<String>): Flow<Set<T>> { inline fun <reified T> AppPreferencesStore.getObjectSet(key: Preferences.Key<String>): Flow<Set<T>> {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java)) return flow {
return data.map { val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
it[key]?.let(adapter::fromJson) ?: emptySet() emitAll(
data.map {
it[key]?.let(adapter::fromJson) ?: emptySet()
},
)
} }
} }

View file

@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration(
private val keyName: String, private val keyName: String,
) : DataMigration<Preferences> { ) : DataMigration<Preferences> {
private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) private val legacyPrefs by lazy {
context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE)
}
override suspend fun cleanUp() { override suspend fun cleanUp() {
val sharedPrefsEditor = legacyPrefs.edit() val sharedPrefsEditor = legacyPrefs.edit()

View file

@ -7,6 +7,7 @@ import com.squareup.moshi.Types
import com.squareup.moshi.adapter import com.squareup.moshi.adapter
import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerifyOrder import io.mockk.coVerifyOrder
import io.mockk.every import io.mockk.every
@ -22,7 +23,11 @@ class AssetLoaderTest {
private val assetReader = mockk<AssetReader>() private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>() private val moshi = mockk<Moshi>()
private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test @Test
fun load() = runTest { fun load() = runTest {

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.asset.reader
import android.content.res.AssetManager import android.content.res.AssetManager
import com.google.common.truth.Truth import com.google.common.truth.Truth
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -15,7 +14,7 @@ import java.io.IOException
internal class AndroidAssetReaderTest { internal class AndroidAssetReaderTest {
private val assetManager = mockk<AssetManager>() private val assetManager = mockk<AssetManager>()
private val assetReader = AndroidAssetReader(assetManager, TestingCoroutineDispatcherProvider()) private val assetReader = AndroidAssetReader(assetManager)
@Test @Test
fun read_content() = runTest { fun read_content() = runTest {