Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 19:19:33 +05:00
parent 26bba893ff
commit 51e650c360
47 changed files with 1628 additions and 73 deletions

1
domain/app-update/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,19 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
implementation(projects.core.utils)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
// region Test
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
}

View file

@ -0,0 +1,28 @@
package com.tangem.domain.appupdate.model
/**
* Result of checking whether the application needs an update.
*/
enum class AppUpdateState {
/** Update is mandatory — a blocking screen with an "Update now" button must be shown. */
ForceUpdate,
/**
* Update is mandatory but impossible on this device (OS too old for the critical version)
* a permanently blocking "brick" screen must be shown.
*/
Brick,
/**
* Update is mandatory but the device OS is too old for the min-supported version a blocking
* "update your OS" screen must be shown.
*/
OsTooOld,
/** Update is available but optional — a dismissible screen may be shown. */
OptionalUpdate,
/** No update is required. */
NoUpdate,
}

View file

@ -0,0 +1,37 @@
package com.tangem.domain.appupdate.model
internal class AppVersion private constructor(
private val major: Int,
private val minor: Int,
private val fix: Int,
) : Comparable<AppVersion> {
override fun compareTo(other: AppVersion): Int {
major.compareTo(other.major).let { if (it != 0) return it }
minor.compareTo(other.minor).let { if (it != 0) return it }
return fix.compareTo(other.fix)
}
companion object {
private const val DELIMITER = "."
private const val MAJOR = 0
private const val MINOR = 1
private const val FIX = 2
fun parseOrNull(value: String): AppVersion? {
// Drop build-type/pre-release suffixes ("6.1-internal", "1.0.0-SNAPSHOT") before parsing.
val parts = value.trim().substringBefore('-').substringBefore('+').split(DELIMITER)
val major = parts.getOrNull(MAJOR)?.toIntOrNull() ?: return null
val minor = parts.getOrNull(MINOR).toVersionPartOrNull() ?: return null
val fix = parts.getOrNull(FIX).toVersionPartOrNull() ?: return null
return AppVersion(major = major, minor = minor, fix = fix)
}
private fun String?.toVersionPartOrNull(): Int? = when (this) {
null -> 0
else -> toIntOrNull()
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.appupdate.model
/**
* Backend-driven update policy. All fields are nullable; a null threshold skips its check.
*
* @property minSupportedVersion mandatory-update threshold (inclusive): `installedVersion <= minSupportedVersion`
* @property minSupportedOSVersion OS threshold for the min-supported case (exclusive): `deviceOsVersion < it` -> OS too old
* @property criticalVersion critical-update threshold (inclusive): `installedVersion <= criticalVersion`
* @property criticalOSVersion OS threshold for the critical case (exclusive): `deviceOsVersion < it` -> brick
* @property latestVersion optional-update threshold (exclusive): `installedVersion < latestVersion`
*/
data class AppVersionInfo(
val minSupportedVersion: String?,
val minSupportedOSVersion: String?,
val criticalVersion: String?,
val criticalOSVersion: String?,
val latestVersion: String?,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.appupdate.model
data class OptionalUpdateShown(
val version: String,
val shownAtMillis: Long,
)

View file

@ -0,0 +1,21 @@
package com.tangem.domain.appupdate.repository
import arrow.core.Either
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
interface AppUpdateRepository {
/** Last cached version thresholds, or `null` if nothing has been fetched yet. No network. */
suspend fun getCachedAppVersionInfo(): AppVersionInfo?
/** Wall-clock time (millis) of the last successful fetch, or `null` if nothing has been fetched yet. */
suspend fun getCachedAppVersionTimestamp(): Long?
/** Fetches fresh thresholds and, on success, overwrites the cache (and its timestamp). */
suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo>
suspend fun getOptionalUpdateShown(): OptionalUpdateShown?
suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown)
}

View file

@ -0,0 +1,119 @@
package com.tangem.domain.appupdate.usecase
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.model.AppVersion
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger
class GetAppUpdateStateUseCase(
private val repository: AppUpdateRepository,
private val appInfoProvider: AppInfoProvider,
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
) {
/**
* Instant decision computed from the cached thresholds and the current app/OS version. No network.
* Records the optional update as shown when it decides to show it (24h throttle). Never throws
* any failure resolves to [AppUpdateState.NoUpdate] so the initial navigation is never blocked.
*/
suspend fun getCached(): AppUpdateState = runSuspendCatching {
resolve(freshCachedInfoOrNull(), recordOptionalShown = true)
}.getOrElse { error ->
TangemLogger.e("Unable to resolve cached app update state", error)
AppUpdateState.NoUpdate
}
/**
* Fetches fresh thresholds (overwriting the cache) and re-evaluates. Falls back to the cache on a
* network error. Does not record the optional update used for background and on-screen refreshes.
* Never throws any failure resolves to [AppUpdateState.NoUpdate].
*/
suspend fun refresh(): AppUpdateState = runSuspendCatching {
val info = repository.refreshAppVersionInfo().getOrNull() ?: freshCachedInfoOrNull()
resolve(info, recordOptionalShown = false)
}.getOrElse { error ->
TangemLogger.e("Unable to resolve app update state", error)
AppUpdateState.NoUpdate
}
/**
* Cached thresholds, but only while they are still fresh. A cache older than [CACHE_TTL_MILLIS] is
* ignored so a permanently unreachable backend can't keep the user blocked forever a successful
* fetch is required at least once per TTL window to keep a blocking threshold in effect.
*/
private suspend fun freshCachedInfoOrNull(): AppVersionInfo? {
val cachedAt = repository.getCachedAppVersionTimestamp() ?: return null
if (currentTimeMillis() - cachedAt > CACHE_TTL_MILLIS) return null
return repository.getCachedAppVersionInfo()
}
private suspend fun resolve(info: AppVersionInfo?, recordOptionalShown: Boolean): AppUpdateState {
info ?: return AppUpdateState.NoUpdate
val appVersion = AppVersion.parseOrNull(appInfoProvider.appVersion) ?: return AppUpdateState.NoUpdate
val deviceOsVersion = AppVersion.parseOrNull(appInfoProvider.osVersion)
val latestVersion = info.latestVersion?.let(AppVersion::parseOrNull)
val criticalVersion = info.criticalVersion?.let(AppVersion::parseOrNull)
if (criticalVersion != null && appVersion <= criticalVersion && isEscapable(latestVersion, criticalVersion)) {
return blockingStateFor(info.criticalOSVersion, deviceOsVersion, AppUpdateState.Brick)
}
val minSupportedVersion = info.minSupportedVersion?.let(AppVersion::parseOrNull)
if (minSupportedVersion != null &&
appVersion <= minSupportedVersion &&
isEscapable(latestVersion, minSupportedVersion)
) {
return blockingStateFor(info.minSupportedOSVersion, deviceOsVersion, AppUpdateState.OsTooOld)
}
if (info.latestVersion != null && latestVersion != null && appVersion < latestVersion) {
return resolveOptionalUpdate(info.latestVersion, recordOptionalShown)
}
return AppUpdateState.NoUpdate
}
/**
* A blocking threshold is honored only if the advertised latest version is strictly above it i.e.
* updating actually clears the block. A threshold no installable version can satisfy is a backend
* misconfiguration and is ignored.
*/
private fun isEscapable(latestVersion: AppVersion?, threshold: AppVersion): Boolean =
latestVersion != null && latestVersion > threshold
private suspend fun resolveOptionalUpdate(latestVersion: String, recordOptionalShown: Boolean): AppUpdateState {
if (!recordOptionalShown) return AppUpdateState.OptionalUpdate
val shown = repository.getOptionalUpdateShown()
val isThrottled = shown != null &&
shown.version == latestVersion &&
currentTimeMillis() - shown.shownAtMillis < OPTIONAL_UPDATE_INTERVAL_MILLIS
if (isThrottled) return AppUpdateState.NoUpdate
repository.setOptionalUpdateShown(
OptionalUpdateShown(version = latestVersion, shownAtMillis = currentTimeMillis()),
)
return AppUpdateState.OptionalUpdate
}
private fun blockingStateFor(
requiredOsVersion: String?,
deviceOsVersion: AppVersion?,
osTooOldState: AppUpdateState,
): AppUpdateState {
val requiredOs = requiredOsVersion?.let(AppVersion::parseOrNull)
val cannotUpdate = requiredOs != null && deviceOsVersion != null && deviceOsVersion < requiredOs
return if (cannotUpdate) osTooOldState else AppUpdateState.ForceUpdate
}
private companion object {
const val OPTIONAL_UPDATE_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val CACHE_TTL_MILLIS = 24L * 60 * 60 * 1000
}
}

View file

@ -0,0 +1,273 @@
package com.tangem.domain.appupdate
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.model.AppVersionInfo
import com.tangem.domain.appupdate.model.OptionalUpdateShown
import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.utils.info.AppInfoProvider
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class GetAppUpdateStateUseCaseTest {
private val repository = mockk<AppUpdateRepository>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val useCase = GetAppUpdateStateUseCase(repository, appInfoProvider, currentTimeMillis = { NOW })
private fun givenCached(appVersion: String = "5.0", osVersion: String = "14", info: AppVersionInfo?) {
every { appInfoProvider.appVersion } returns appVersion
every { appInfoProvider.osVersion } returns osVersion
coEvery { repository.getCachedAppVersionInfo() } returns info
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
coEvery { repository.getOptionalUpdateShown() } returns null
coEvery { repository.setOptionalUpdateShown(any()) } just Runs
}
private fun givenRefresh(appVersion: String = "5.0", osVersion: String = "14", info: AppVersionInfo) {
every { appInfoProvider.appVersion } returns appVersion
every { appInfoProvider.osVersion } returns osVersion
coEvery { repository.refreshAppVersionInfo() } returns info.right()
coEvery { repository.getOptionalUpdateShown() } returns null
coEvery { repository.setOptionalUpdateShown(any()) } just Runs
}
private fun info(
minSupportedVersion: String? = null,
minSupportedOSVersion: String? = null,
criticalVersion: String? = null,
criticalOSVersion: String? = null,
latestVersion: String? = null,
) = AppVersionInfo(
minSupportedVersion = minSupportedVersion,
minSupportedOSVersion = minSupportedOSVersion,
criticalVersion = criticalVersion,
criticalOSVersion = criticalOSVersion,
latestVersion = latestVersion,
)
@Test
fun `GIVEN app at critical version and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "14",
info = info(criticalVersion = "5.0", criticalOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN app at critical version and OS too old WHEN getCached THEN Brick`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "9",
info = info(criticalVersion = "5.0", criticalOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.Brick)
}
@Test
fun `GIVEN app at min supported and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "14",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN app at min supported and OS too old WHEN getCached THEN OsTooOld`() = runTest {
givenCached(
appVersion = "5.0",
osVersion = "9",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OsTooOld)
}
@Test
fun `GIVEN critical above latest WHEN getCached THEN not blocking and degraded to optional`() = runTest {
givenCached(appVersion = "5.20", info = info(criticalVersion = "9.99", latestVersion = "5.41"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN min supported above latest WHEN getCached THEN not blocking and degraded to optional`() = runTest {
givenCached(appVersion = "5.20", info = info(minSupportedVersion = "9.99", latestVersion = "5.41"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN blocking threshold but no latest WHEN getCached THEN ignored as NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", criticalOSVersion = "10"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN app below latest and not shown before WHEN getCached THEN OptionalUpdate is recorded`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
coVerify(exactly = 1) {
repository.setOptionalUpdateShown(OptionalUpdateShown(version = "5.37", shownAtMillis = NOW))
}
}
@Test
fun `GIVEN app at latest WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.37", info = info(latestVersion = "5.37"))
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN optional shown for same version within 24h WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW - DAY_MILLIS + 1)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
}
@Test
fun `GIVEN optional shown for same version over 24h ago WHEN getCached THEN OptionalUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW - DAY_MILLIS - 1)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN optional shown for older version WHEN getCached THEN OptionalUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.36", shownAtMillis = NOW)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN all thresholds null WHEN getCached THEN NoUpdate`() = runTest {
givenCached(info = info())
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN critical and latest both match WHEN getCached THEN critical wins`() = runTest {
givenCached(
appVersion = "3.0",
osVersion = "14",
info = info(criticalVersion = "3.0", minSupportedVersion = "3.0", latestVersion = "5.37"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN no cache WHEN getCached THEN NoUpdate`() = runTest {
givenCached(info = null)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN cache older than TTL WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS - 1
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN cache exactly at TTL WHEN getCached THEN still blocks`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN cache without timestamp WHEN getCached THEN NoUpdate`() = runTest {
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
coEvery { repository.getCachedAppVersionTimestamp() } returns null
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN refresh fails and cache stale WHEN refresh THEN NoUpdate`() = runTest {
every { appInfoProvider.appVersion } returns "5.0"
every { appInfoProvider.osVersion } returns "14"
coEvery { repository.refreshAppVersionInfo() } returns IllegalStateException("error").left()
coEvery { repository.getCachedAppVersionInfo() } returns info(criticalVersion = "5.0", latestVersion = "5.1")
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS - 1
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN refresh returns blocking info WHEN refresh THEN ForceUpdate`() = runTest {
givenRefresh(appVersion = "5.0", osVersion = "14", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN refresh returns optional info WHEN refresh THEN OptionalUpdate without recording`() = runTest {
givenRefresh(appVersion = "5.0", info = info(latestVersion = "5.37"))
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.OptionalUpdate)
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
}
@Test
fun `GIVEN refresh fails WHEN refresh THEN falls back to cached thresholds`() = runTest {
every { appInfoProvider.appVersion } returns "5.0"
every { appInfoProvider.osVersion } returns "14"
coEvery { repository.refreshAppVersionInfo() } returns IllegalStateException("error").left()
coEvery { repository.getCachedAppVersionInfo() } returns info(criticalVersion = "5.0", latestVersion = "5.1")
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.ForceUpdate)
}
@Test
fun `GIVEN repository throws WHEN getCached THEN NoUpdate`() = runTest {
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
coEvery { repository.getCachedAppVersionInfo() } throws IllegalStateException("boom")
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
}
@Test
fun `GIVEN repository throws WHEN refresh THEN NoUpdate`() = runTest {
coEvery { repository.refreshAppVersionInfo() } throws IllegalStateException("boom")
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.NoUpdate)
}
private companion object {
const val NOW = 1_000_000_000_000L
const val DAY_MILLIS = 24L * 60 * 60 * 1000
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.domain.appupdate.model
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
internal class AppVersionTest {
@Test
fun `GIVEN single segment WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("14")).isNotNull()
}
@Test
fun `GIVEN major and minor WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("5.30")).isNotNull()
}
@Test
fun `GIVEN major minor fix WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("5.40.1")).isNotNull()
}
@Test
fun `GIVEN empty minor part WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("5..1")).isNull()
}
@Test
fun `GIVEN non-numeric part WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("5.x")).isNull()
}
@Test
fun `GIVEN blank WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("")).isNull()
}
@Test
fun `GIVEN trailing dot WHEN parse THEN null`() {
assertThat(AppVersion.parseOrNull("5.")).isNull()
}
@Test
fun `GIVEN older version WHEN compare THEN less than newer`() {
assertThat(AppVersion.parseOrNull("14")!! < AppVersion.parseOrNull("15.0")!!).isTrue()
}
@Test
fun `GIVEN fix difference WHEN compare THEN ordered`() {
assertThat(AppVersion.parseOrNull("5.40.1")!! > AppVersion.parseOrNull("5.40.0")!!).isTrue()
}
@Test
fun `GIVEN missing minor WHEN compare to explicit zero THEN equal`() {
val implicit = AppVersion.parseOrNull("14")!!
val explicit = AppVersion.parseOrNull("14.0")!!
assertThat(implicit.compareTo(explicit)).isEqualTo(0)
}
@Test
fun `GIVEN build-type suffix WHEN parse THEN parsed without suffix`() {
assertThat(AppVersion.parseOrNull("6.1-internal")).isNotNull()
}
@Test
fun `GIVEN snapshot fallback WHEN parse THEN parsed`() {
assertThat(AppVersion.parseOrNull("1.0.0-SNAPSHOT")).isNotNull()
}
@Test
fun `GIVEN suffixed version WHEN compare to clean THEN equal`() {
val suffixed = AppVersion.parseOrNull("6.1-internal")!!
val clean = AppVersion.parseOrNull("6.1")!!
assertThat(suffixed.compareTo(clean)).isEqualTo(0)
}
}