Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-17 10:59:26 +02:00
parent 98d0d9c75e
commit b1c19eff9e
21 changed files with 538 additions and 52 deletions

View file

@ -31,4 +31,11 @@ sealed interface AddressBookSyncError {
/** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */
data object Unknown : AddressBookSyncError
/**
* The stored book uses a contract version newer than this build supports
* ([com.tangem.domain.addressbook.model.AddressBookBlob.isVersionCompatible]). Determined locally (no
* network), the write is refused so a book the app cannot fully understand is not downgraded/overwritten.
*/
data object VersionMismatch : AddressBookSyncError
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.addressbook.model.AddressBookBlob.Companion.CURRENT_VERSION
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -26,7 +27,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class AddressBookBlob(
@SerialName("version")
val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY]
val version: String = CURRENT_VERSION,
@SerialName("walletId")
val walletId: String,
@SerialName("updatedAt")
@ -39,7 +40,30 @@ data class AddressBookBlob(
val authTag: String,
) {
/**
* Whether this build can safely read and write the blob i.e. its [version] is not newer than the
* contract this app supports ([CURRENT_VERSION]). See [isVersionCompatible].
*/
val isVersionCompatible: Boolean get() = isVersionCompatible(version)
companion object {
const val CURRENT_VERSION = "1.0"
/**
* A blob is compatible when its contract version is **not higher** than [CURRENT_VERSION]. The version
* is treated as a plain number (major/minor are not distinguished any higher value is incompatible):
* - lower (`0.9`) the app understands a newer contract; reads work and a write upgrades the backend
* copy to [CURRENT_VERSION].
* - equal same contract.
* - higher (`1.1`, `2.0`) the backend contract is newer than this build understands, so the book must
* be treated as read-only-opaque (not read, not written).
*
* A version that cannot be parsed as a number is treated as incompatible safer to refuse a blob we
* cannot reason about.
*/
fun isVersionCompatible(version: String): Boolean {
val parsed = version.trim().toDoubleOrNull() ?: return false
return parsed <= CURRENT_VERSION.toDouble()
}
}
}

View file

@ -20,6 +20,14 @@ interface AddressBookRepository {
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
/**
* Whether the stored address book(s) can be used by this build i.e. their contract version is not newer
* than the one this app supports (see [com.tangem.domain.addressbook.model.AddressBookBlob.isVersionCompatible]).
* @param userWalletId a specific wallet, or `null` to check every wallet `null` is compatible only when
* **all** currently stored books are compatible.
*/
fun isAddressBookCompatible(userWalletId: UserWalletId? = null): Flow<Boolean>
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>
suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit>

View file

@ -0,0 +1,20 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Emits whether the stored address book(s) can be used by this build `false` when the backend contract
* version is newer than the one this app supports, so consumers can degrade (hide the book, block editing).
*
* @param userWalletId a specific wallet, or `null` to check every wallet (compatible only when all stored
* books are compatible). See [AddressBookRepository.isAddressBookCompatible].
*/
class IsAddressBookCompatibleUseCase(
private val repository: AddressBookRepository,
) {
operator fun invoke(userWalletId: UserWalletId? = null): Flow<Boolean> =
repository.isAddressBookCompatible(userWalletId)
}

View file

@ -0,0 +1,47 @@
package com.tangem.domain.addressbook.model
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AddressBookBlobVersionTest {
@ParameterizedTest
@MethodSource("provideVersions")
fun `isVersionCompatible against CURRENT_VERSION`(model: VersionModel) {
// CURRENT_VERSION is 1.0 — the cases below are written relative to it.
assertThat(AddressBookBlob.isVersionCompatible(model.version)).isEqualTo(model.expectedCompatible)
}
@Test
fun `blob property mirrors the version function`() {
val newer = AddressBookBlob(
version = "2.0",
walletId = "w",
updatedAt = "t",
nonce = "n",
ciphertext = "c",
authTag = "a",
)
assertThat(newer.isVersionCompatible).isFalse()
}
internal data class VersionModel(val version: String, val expectedCompatible: Boolean)
private fun provideVersions() = listOf(
VersionModel(version = "1.0", expectedCompatible = true), // equal
VersionModel(version = "0.9", expectedCompatible = true), // lower
VersionModel(version = "0.5", expectedCompatible = true), // lower
VersionModel(version = "1", expectedCompatible = true), // 1 == 1.0
VersionModel(version = "1.1", expectedCompatible = false), // higher
VersionModel(version = "2.0", expectedCompatible = false), // higher
VersionModel(version = "1.10", expectedCompatible = false), // 1.10 == 1.1 as a number, higher than 1.0
VersionModel(version = "", expectedCompatible = false), // not a number
VersionModel(version = "1.0.0", expectedCompatible = false), // not a number
VersionModel(version = "abc", expectedCompatible = false) // not a number
)
}