Skip to content

Commit d53448b

Browse files
committed
Исправить замечания независимого ревью
1 parent cbe49fc commit d53448b

16 files changed

Lines changed: 332 additions & 37 deletions

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
- точки графика размещаются по реальным календарным датам, а пропуски данных явно обозначаются;
1010
- кэш истории ограничен, загрузка имеет общий отменяемый тайм-аут, а при ошибке используется сохранённый график;
1111
- резервные адреса текущих и исторических курсов проверяются не только по HTTP-ответу, но и по содержимому данных;
12-
- статус курса учитывает мировой резерв для валют, отсутствующих в данных Банка России;
13-
- после перезапуска восстанавливается последний открытый экран;
12+
- статус курса учитывает только реально используемые автоматические источники, включая смешанные наборы ЦБ и ручных курсов;
13+
- после перезапуска восстанавливаются последний открытый экран, валютная пара, период и сумма графика;
14+
- смена источника во время обновления не теряет последующий запрос;
1415
- график получил действия для TalkBack;
15-
- проект переведён на Android API 36, Gradle 8.14.5 и Android Gradle Plugin 8.11.1;
16+
- проект переведён на Android API 36, Gradle 8.14.5, Android Gradle Plugin 8.11.1 и согласованный Compose BOM;
1617
- CI проверяет релизную сборку и запуск на Android 15, раздельно сохраняет APK и таблицу деобфускации и подтверждает происхождение APK из тегов;
1718
- добавлены Dependabot, инструкция проверяемого выпуска и сведения о происхождении звука.
1819

RELEASE_NOTES_1.8.1.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
- выбор дат графика доступен через действия TalkBack;
1111
- кэш истории ограничен 24 наборами, а одна загрузка — 60 секундами; при тайм-ауте доступен ранее сохранённый график;
1212
- резервные источники используются и тогда, когда основной адрес ответил некорректными или устаревшими данными;
13-
- в режиме Банка России предупреждение учитывает мировой резерв для отсутствующих у ЦБ валют;
14-
- последний открытый экран восстанавливается после перезапуска;
13+
- предупреждение учитывает только источники, которые действительно используются выбранными валютами ЦБ или ручными курсами;
14+
- последний открытый экран, валютная пара, период и сумма графика восстанавливаются после перезапуска;
15+
- смена источника во время загрузки безопасно запускает необходимое повторное обновление;
1516
- целевая версия Android обновлена до API 36;
1617
- CI собирает debug и release, запускает инструментальные тесты на Android 15, раздельно сохраняет APK и таблицу деобфускации, а для тега создаёт аттестацию происхождения неподписанного APK.
1718

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ android {
5050
}
5151

5252
dependencies {
53-
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
53+
val composeBom = platform("androidx.compose:compose-bom:2025.09.01")
5454

5555
implementation(composeBom)
5656
androidTestImplementation(composeBom)

app/src/androidTest/java/ru/abrikosov/cleanrate/AndroidRuntimeSmokeTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import org.junit.Assert.assertTrue
1010
import org.junit.Test
1111
import org.junit.runner.RunWith
1212
import ru.abrikosov.cleanrate.data.CbrRepository
13+
import ru.abrikosov.cleanrate.data.ChartPeriod
1314
import ru.abrikosov.cleanrate.data.CurrencyRepository
15+
import ru.abrikosov.cleanrate.data.HistoricalRatesRepository
1416

1517
@RunWith(AndroidJUnit4::class)
1618
class AndroidRuntimeSmokeTest {
@@ -46,4 +48,36 @@ class AndroidRuntimeSmokeTest {
4648
repository.saveChartVisible(originalValue)
4749
}
4850
}
51+
52+
@Test
53+
fun chartSelectionAndAmountPersistInApplicationStorage() {
54+
val context = InstrumentationRegistry.getInstrumentation().targetContext
55+
val repository = HistoricalRatesRepository(context)
56+
val original = repository.loadSelection(defaultBase = "USD", defaultQuote = "EUR")
57+
58+
try {
59+
repository.saveSelection(
60+
baseCode = "RUB",
61+
quoteCode = "MGA",
62+
period = ChartPeriod.THREE_MONTHS,
63+
amountText = "1600000,5",
64+
)
65+
66+
val restored = HistoricalRatesRepository(context).loadSelection(
67+
defaultBase = "USD",
68+
defaultQuote = "EUR",
69+
)
70+
assertEquals("RUB", restored.baseCode)
71+
assertEquals("MGA", restored.quoteCode)
72+
assertEquals(ChartPeriod.THREE_MONTHS, restored.period)
73+
assertEquals("1600000,5", restored.amountText)
74+
} finally {
75+
repository.saveSelection(
76+
baseCode = original.baseCode,
77+
quoteCode = original.quoteCode,
78+
period = original.period,
79+
amountText = original.amountText,
80+
)
81+
}
82+
}
4983
}

app/src/main/java/ru/abrikosov/cleanrate/data/HistoricalRatesRepository.kt

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,37 @@ class HistoricalRatesRepository(context: Context) {
6666
val period = runCatching {
6767
ChartPeriod.valueOf(preferences.getString(KEY_SELECTED_PERIOD, null) ?: ChartPeriod.MONTH.name)
6868
}.getOrDefault(ChartPeriod.MONTH)
69-
return HistorySelection(baseCode = base, quoteCode = quote, period = period)
69+
val amountText = validatedHistoryAmountText(
70+
preferences.getString(KEY_SELECTED_AMOUNT, null),
71+
)
72+
return HistorySelection(
73+
baseCode = base,
74+
quoteCode = quote,
75+
period = period,
76+
amountText = amountText,
77+
)
7078
}
7179

72-
fun saveSelection(baseCode: String, quoteCode: String, period: ChartPeriod) {
80+
fun saveSelection(
81+
baseCode: String,
82+
quoteCode: String,
83+
period: ChartPeriod,
84+
amountText: String?,
85+
) {
7386
preferences.edit {
7487
putString(KEY_SELECTED_BASE, baseCode)
7588
putString(KEY_SELECTED_QUOTE, quoteCode)
7689
putString(KEY_SELECTED_PERIOD, period.name)
90+
if (amountText == null) {
91+
remove(KEY_SELECTED_AMOUNT)
92+
} else {
93+
val validatedAmount = validatedHistoryAmountText(amountText)
94+
if (validatedAmount == null) {
95+
remove(KEY_SELECTED_AMOUNT)
96+
} else {
97+
putString(KEY_SELECTED_AMOUNT, validatedAmount)
98+
}
99+
}
77100
}
78101
}
79102

@@ -158,6 +181,7 @@ class HistoricalRatesRepository(context: Context) {
158181
baseCode = baseCode,
159182
quoteCode = quoteCode,
160183
expectedDate = expectedDate,
184+
requireFresh = expectedDate == null,
161185
),
162186
) {
163187
"Источник истории вернул некорректный курс"
@@ -257,6 +281,7 @@ class HistoricalRatesRepository(context: Context) {
257281
private const val KEY_SELECTED_BASE = "selected_base"
258282
private const val KEY_SELECTED_QUOTE = "selected_quote"
259283
private const val KEY_SELECTED_PERIOD = "selected_period"
284+
private const val KEY_SELECTED_AMOUNT = "selected_amount"
260285
private const val CACHE_KEY_PREFIX = "history_"
261286
private const val MAX_PARALLEL_REQUESTS = 6
262287
private const val MAX_CACHE_ENTRIES = 24

app/src/main/java/ru/abrikosov/cleanrate/data/HistoryModels.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,29 @@ data class HistorySelection(
3737
val baseCode: String,
3838
val quoteCode: String,
3939
val period: ChartPeriod,
40+
val amountText: String? = null,
4041
)
4142

43+
internal const val MAX_HISTORY_AMOUNT_LENGTH = 24
44+
45+
internal fun filterHistoryAmountText(rawValue: String): String = buildString {
46+
var hasSeparator = false
47+
rawValue.forEach { character ->
48+
when {
49+
character.isDigit() -> append(character)
50+
character in charArrayOf(',', '.') && !hasSeparator -> {
51+
if (isEmpty()) append('0')
52+
append(character)
53+
hasSeparator = true
54+
}
55+
}
56+
}
57+
}.take(MAX_HISTORY_AMOUNT_LENGTH)
58+
59+
internal fun validatedHistoryAmountText(value: String?): String? = value?.takeIf {
60+
filterHistoryAmountText(it) == it
61+
}
62+
4263
object HistorySampling {
4364
fun dates(endDate: LocalDate, period: ChartPeriod): List<LocalDate> {
4465
val startDate = endDate.minusDays(period.daysBack)

app/src/main/java/ru/abrikosov/cleanrate/data/HistoryRateParser.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ object HistoryRateParser {
1010
baseCode: String,
1111
quoteCode: String,
1212
expectedDate: LocalDate? = null,
13+
requireFresh: Boolean = false,
14+
today: LocalDate = LocalDate.now(),
1315
): HistoricalRatePoint? = runCatching {
1416
val normalizedBase = baseCode.lowercase()
1517
val normalizedQuote = quoteCode.lowercase()
@@ -19,6 +21,7 @@ object HistoryRateParser {
1921
val root = JSONObject(rawJson)
2022
val date = LocalDate.parse(root.getString("date"))
2123
if (expectedDate != null) check(date == expectedDate)
24+
check(!requireFresh || RateFreshnessPolicy.isMarketFresh(date, today))
2225

2326
val rates = root.getJSONObject(normalizedBase)
2427
val rate = rates.get(normalizedQuote).toString().toBigDecimalOrNull()

app/src/main/java/ru/abrikosov/cleanrate/ui/ConverterViewModel.kt

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class ConverterViewModel(application: Application) : AndroidViewModel(applicatio
7070
private val restoredSession = marketRepository.loadConverterSession()?.takeIf {
7171
it.activeCode in initialFavorites && it.activeCode in initialMarketSnapshot.rates
7272
}
73+
private val refreshCoordinator = RefreshCoordinator()
7374

7475
private val _uiState = MutableStateFlow(
7576
ConverterUiState(
@@ -95,13 +96,24 @@ class ConverterViewModel(application: Application) : AndroidViewModel(applicatio
9596
}
9697

9798
fun refreshRates(showSuccessMessage: Boolean = true, force: Boolean = true) {
98-
val state = _uiState.value
99-
if (state.isRefreshing) return
99+
val request = refreshCoordinator.submit(
100+
RefreshRequest(
101+
showSuccessMessage = showSuccessMessage,
102+
force = force,
103+
),
104+
) ?: return
105+
startRefresh(request)
106+
}
100107

101-
val refreshMarket = force || marketRepository.shouldRefresh(state.marketSnapshot)
108+
private fun startRefresh(request: RefreshRequest) {
109+
val state = _uiState.value
110+
val refreshMarket = request.force || marketRepository.shouldRefresh(state.marketSnapshot)
102111
val refreshCbr = state.rateSource == RateSource.CBR &&
103-
(force || cbrRepository.shouldRefresh(state.cbrSnapshot))
104-
if (!refreshMarket && !refreshCbr) return
112+
(request.force || cbrRepository.shouldRefresh(state.cbrSnapshot))
113+
if (!refreshMarket && !refreshCbr) {
114+
continueWithPendingRefresh()
115+
return
116+
}
105117

106118
_uiState.update { it.copy(isRefreshing = true) }
107119
viewModelScope.launch {
@@ -123,14 +135,19 @@ class ConverterViewModel(application: Application) : AndroidViewModel(applicatio
123135
isRefreshing = false,
124136
message = when {
125137
hasFailure -> UiMessage.SourcesUnavailable
126-
showSuccessMessage -> UiMessage.RatesUpdated
138+
request.showSuccessMessage -> UiMessage.RatesUpdated
127139
else -> null
128140
},
129141
)
130142
}
143+
continueWithPendingRefresh()
131144
}
132145
}
133146

147+
private fun continueWithPendingRefresh() {
148+
refreshCoordinator.complete()?.let(::startRefresh)
149+
}
150+
134151
fun selectRateSource(source: RateSource) {
135152
if (_uiState.value.rateSource == source) return
136153
marketRepository.saveRateSource(source)

app/src/main/java/ru/abrikosov/cleanrate/ui/HistoryViewModel.kt

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
1414
import ru.abrikosov.cleanrate.data.ChartPeriod
1515
import ru.abrikosov.cleanrate.data.HistoricalRatePoint
1616
import ru.abrikosov.cleanrate.data.HistoricalRatesRepository
17+
import ru.abrikosov.cleanrate.data.filterHistoryAmountText
1718

1819
data class HistoryUiState(
1920
val baseCode: String = "RUB",
@@ -60,7 +61,8 @@ class HistoryViewModel(application: Application) : AndroidViewModel(application)
6061
baseCode = selection.baseCode,
6162
quoteCode = selection.quoteCode,
6263
period = selection.period,
63-
amountText = editableNumber(defaultChartAmount(amount)),
64+
amountText = selection.amountText
65+
?: filterHistoryAmountText(editableNumber(defaultChartAmount(amount))),
6466
initialized = true,
6567
)
6668
load(clearExisting = true)
@@ -102,20 +104,8 @@ class HistoryViewModel(application: Application) : AndroidViewModel(application)
102104
}
103105

104106
fun setAmount(rawValue: String) {
105-
val filtered = buildString {
106-
var hasSeparator = false
107-
rawValue.forEach { character ->
108-
when {
109-
character.isDigit() -> append(character)
110-
character in charArrayOf(',', '.') && !hasSeparator -> {
111-
if (isEmpty()) append('0')
112-
append(character)
113-
hasSeparator = true
114-
}
115-
}
116-
}
117-
}.take(MAX_AMOUNT_LENGTH)
118-
_uiState.update { it.copy(amountText = filtered) }
107+
_uiState.update { it.copy(amountText = filterHistoryAmountText(rawValue)) }
108+
saveSelection()
119109
}
120110

121111
fun refresh() {
@@ -187,17 +177,18 @@ class HistoryViewModel(application: Application) : AndroidViewModel(application)
187177

188178
private fun saveSelection() {
189179
val state = _uiState.value
190-
repository.saveSelection(state.baseCode, state.quoteCode, state.period)
180+
repository.saveSelection(
181+
baseCode = state.baseCode,
182+
quoteCode = state.quoteCode,
183+
period = state.period,
184+
amountText = state.amountText,
185+
)
191186
}
192187

193188
private fun editableNumber(value: BigDecimal): String = value
194189
.setScale(8, RoundingMode.HALF_UP)
195190
.stripTrailingZeros()
196191
.toPlainString()
197-
198-
companion object {
199-
private const val MAX_AMOUNT_LENGTH = 24
200-
}
201192
}
202193

203194
internal fun defaultChartAmount(amount: BigDecimal): BigDecimal =

app/src/main/java/ru/abrikosov/cleanrate/ui/RateStatusPolicy.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,19 @@ internal object RateStatusPolicy {
2121
lastCheckedEpochSeconds = state.cbrSnapshot.lastCheckedEpochSeconds,
2222
)
2323
return when (state.rateSource) {
24-
RateSource.MARKET, RateSource.CUSTOM -> market
24+
RateSource.MARKET -> market
2525
RateSource.CBR -> {
2626
val usesMarketFallback = state.favorites.any { code ->
2727
code !in state.cbrSnapshot.rates
2828
}
2929
if (usesMarketFallback) merge(cbr, market) else cbr
3030
}
31+
RateSource.CUSTOM -> {
32+
val usesMarketFallback = state.favorites.any { code ->
33+
code != USD_CODE && code !in state.manualRates
34+
}
35+
if (usesMarketFallback) market else LOCAL_RATES_HEALTH
36+
}
3137
}
3238
}
3339

@@ -45,4 +51,11 @@ internal object RateStatusPolicy {
4551
?.minOrNull(),
4652
)
4753
}
54+
55+
private val LOCAL_RATES_HEALTH = RateStatusHealth(
56+
loadedFromSeed = false,
57+
isStale = false,
58+
lastCheckedEpochSeconds = null,
59+
)
60+
private const val USD_CODE = "USD"
4861
}

0 commit comments

Comments
 (0)