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

@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.utils.coroutines.runCatching
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -23,69 +25,63 @@ import javax.inject.Singleton
class AssetLoader @Inject constructor(
val assetReader: AssetReader,
@NetworkMoshi val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
) {
/** Load content [Content] of asset file [fileName] */
@OptIn(ExperimentalStdlibApi::class)
suspend inline fun <reified Content> load(fileName: String): Content? {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified Content> load(fileName: String): Content? = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.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
},
)
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
},
)
/** Load list [V] values of asset file [fileName] */
suspend inline fun <reified V> loadList(fileName: String): List<V> {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified V> loadList(fileName: String): List<V> = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
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()
},
)
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()
},
)
/** Load map [String] keys and [V] values of asset file [fileName] */
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
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)
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()
},
)
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()
},
)
}

View file

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

View file

@ -2,7 +2,6 @@ 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
@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule {
return AppPreferencesStore(
preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io),
moshi = moshi,
dispatchers = dispatchers,
)
}
}

View file

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

View file

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

View file

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

View file

@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration(
private val keyName: String,
) : 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() {
val sharedPrefsEditor = legacyPrefs.edit()

View file

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

View file

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