diff --git a/app/src/main/java/life/andre/message487/AppUpdates.kt b/app/src/main/java/life/andre/message487/AppUpdates.kt index 3b24da2..89d39af 100644 --- a/app/src/main/java/life/andre/message487/AppUpdates.kt +++ b/app/src/main/java/life/andre/message487/AppUpdates.kt @@ -97,6 +97,13 @@ internal fun validateUpdateIdentity( require(signers.isNotEmpty() && signers == installedSigners) } +internal fun requireUpdateMetadataStatus(status: Int) { + if (status == 404) throw UpdateException(R.string.update_not_published) + if (status == 429 || status in 500..599) + throw java.io.IOException("Update service temporarily unavailable") + require(status == 200) +} + internal class AppUpdates(private val context: Context) { private val preferences = context.getSharedPreferences("updates", Context.MODE_PRIVATE) private val manager = context.packageManager @@ -129,8 +136,7 @@ internal class AppUpdates(private val context: Context) { suspend fun check(source: UpdateSource): AppUpdate? = withContext(Dispatchers.IO) { val url = if (source == UpdateSource.FDROID) FDROID_UPDATE_API else GITHUB_RELEASES_API val json = request(url) { connection -> - if (connection.responseCode == 404) throw UpdateException(R.string.update_not_published) - require(connection.responseCode == 200) + requireUpdateMetadataStatus(connection.responseCode) connection.inputStream.use { stream -> String(readUpdateMetadata(stream), Charsets.UTF_8) } diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index f4b99e2..b1653bb 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -33,7 +33,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() if (intent.action == OPEN_UPDATES_ACTION) updateCheckRequest++ - setContent { MessageTheme { MessageScreen(checkRequest = updateCheckRequest) } } + setContent { MessageTheme { + MessageScreen(checkRequest = updateCheckRequest) + UpdateAvailableDialog(this, updateCheckRequest) { updateCheckRequest++ } + } } } override fun onNewIntent(intent: Intent) { diff --git a/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt b/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt new file mode 100644 index 0000000..6b26657 --- /dev/null +++ b/app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt @@ -0,0 +1,80 @@ +package life.andre.message487 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource + +@Composable +internal fun UpdateAvailableDialog(activity: Activity, openRequest: Int, onOpen: () -> Unit) { + val lifecycle = LocalLifecycleOwner.current + val preferences = remember { UpdatePreferences(activity) } + var update by remember { mutableStateOf(null) } + var openFailed by remember { mutableStateOf(false) } + DisposableEffect(lifecycle, openRequest) { + var suppressNextRefresh = openRequest > 0 + fun refresh() { + update = if (suppressNextRefresh) null else preferences.pending() + suppressNextRefresh = false + openFailed = false + } + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) refresh() + } + // Registering replays the current lifecycle, including ON_RESUME. + lifecycle.lifecycle.addObserver(observer) + onDispose { lifecycle.lifecycle.removeObserver(observer) } + } + val candidate = update ?: return + fun later() { + preferences.remindLater(candidate) + UpdateNotifications.cancel(activity) + update = null + } + AlertDialog( + onDismissRequest = ::later, + title = { Text(stringResource(R.string.update_available, candidate.version)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + val label = stringResource(if (candidate.source == UpdateSource.FDROID) R.string.update_fdroid else R.string.update_github) + Text(stringResource(R.string.update_notification_text, label)) + if (openFailed) Text(stringResource(R.string.update_open_error)) + TextButton(onClick = ::later) { Text(stringResource(R.string.update_remind_later)) } + TextButton(onClick = { + preferences.skip(UpdatePreferences.key(candidate)) + UpdateNotifications.cancel(activity) + update = null + }) { Text(stringResource(R.string.update_skip)) } + } + }, + confirmButton = { + TextButton(onClick = { + if (candidate.source == UpdateSource.FDROID) { + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(FDROID_APP_URL))) + later() + } catch (_: Exception) { openFailed = true } + } else { + later() + onOpen() + } + }) { Text(stringResource(if (candidate.source == UpdateSource.FDROID) R.string.update_in_fdroid else R.string.update_notification_open)) } + }, + ) +} diff --git a/app/src/main/java/life/andre/message487/UpdateNotifications.kt b/app/src/main/java/life/andre/message487/UpdateNotifications.kt index 1e654fa..de6e77e 100644 --- a/app/src/main/java/life/andre/message487/UpdateNotifications.kt +++ b/app/src/main/java/life/andre/message487/UpdateNotifications.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit internal val UPDATE_REMINDER_INTERVAL = TimeUnit.DAYS.toMillis(7) +internal val UPDATE_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(30) internal const val UPDATE_JOB_ID = 48702 internal const val UPDATE_NOTIFICATION_ID = 48702 internal const val UPDATE_NOTIFICATION_CHANNEL = "app_updates" @@ -33,12 +34,48 @@ internal const val DISABLE_UPDATES_ACTION = "life.andre.message487.DISABLE_UPDAT internal fun shouldNotifyUpdate(key: String, skipped: String?, lastKey: String?, lastTime: Long, now: Long): Boolean = key != skipped && (key != lastKey || now - lastTime >= UPDATE_REMINDER_INTERVAL) -internal class UpdatePreferences(context: Context) { +internal class UpdatePreferences(private val context: Context) { private val prefs = context.getSharedPreferences("updates", Context.MODE_PRIVATE) var automatic: Boolean get() = prefs.getBoolean("automatic", true) set(value) { prefs.edit().putBoolean("automatic", value).apply() } + val lastBackgroundTime: Long get() = prefs.getLong("background_at", 0) + val lastBackgroundResult: String? get() = prefs.getString("background_result", null) + val lastBackgroundSource: String? get() = prefs.getString("background_source", null) + + fun backgroundStarted(source: UpdateSource, now: Long = System.currentTimeMillis()) { + prefs.edit().putLong("background_at", now).putString("background_source", source.name) + .putString("background_result", "running").apply() + } + + fun backgroundFinished(result: String) { + prefs.edit().putString("background_result", result).apply() + } + + fun detected(source: UpdateSource, update: AppUpdate?) { + if (AppUpdates(context).source() != source) return + prefs.edit().apply { + if (update == null) { remove("detected_source"); remove("detected_version") } + else { putString("detected_source", source.name); putString("detected_version", update.version) } + }.apply() + } + + fun pending(now: Long = System.currentTimeMillis()): AppUpdate? { + val source = UpdateSource.entries.firstOrNull { it.name == prefs.getString("detected_source", null) } ?: return null + val version = prefs.getString("detected_version", null) ?: return null + val update = AppUpdate(source, version) + if (AppUpdates(context).source() != source || !runCatching { newerVersion(version, BuildConfig.VERSION_NAME) }.getOrDefault(false)) return null + if (key(update) in prefs.getStringSet("skipped_versions", emptySet()).orEmpty()) return null + if (key(update) == prefs.getString("dialog_key", null) && now < prefs.getLong("dialog_after", 0)) return null + return update + } + + fun remindLater(update: AppUpdate, now: Long = System.currentTimeMillis()) { + prefs.edit().putString("dialog_key", key(update)).putLong("dialog_after", now + UPDATE_REMINDER_INTERVAL) + .putString("notified", key(update)).putLong("notified_at", now).apply() + } + fun shouldNotify(update: AppUpdate, now: Long): Boolean = automatic && shouldNotifyUpdate( key(update), key(update).takeIf { it in prefs.getStringSet("skipped_versions", emptySet()).orEmpty() }, prefs.getString("notified", null), prefs.getLong("notified_at", 0), now) @@ -60,10 +97,11 @@ internal object UpdateNotifications { if (!UpdatePreferences(context).automatic || AppUpdates(context).source() == null) { scheduler.cancel(UPDATE_JOB_ID) cancel(context) - } else if (scheduler.getPendingJob(UPDATE_JOB_ID) == null) { + } else if (scheduler.getPendingJob(UPDATE_JOB_ID)?.initialBackoffMillis != UPDATE_RETRY_INTERVAL) { scheduler.schedule(JobInfo.Builder(UPDATE_JOB_ID, ComponentName(context, UpdateCheckService::class.java)) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) .setPeriodic(TimeUnit.DAYS.toMillis(1), TimeUnit.HOURS.toMillis(1)) + .setBackoffCriteria(UPDATE_RETRY_INTERVAL, JobInfo.BACKOFF_POLICY_EXPONENTIAL) .setPersisted(true) .build()) } @@ -133,18 +171,14 @@ class UpdateCheckService : JobService() { val source = updates.source() ?: return false if (!UpdatePreferences(this).automatic) return false check = scope.launch { - try { - val update = updates.check(source) - if (update != null) UpdateNotifications.show(this@UpdateCheckService, update) - else UpdateNotifications.cancel(this@UpdateCheckService) - } catch (cancelled: CancellationException) { throw cancelled } - catch (_: Exception) { /* Retry at the next scheduled check; never switch source on failure. */ } - jobFinished(params, false) + val retry = runBackgroundUpdateCheck(this@UpdateCheckService, source, updates::check) + jobFinished(params, retry) } return true } override fun onStopJob(params: JobParameters): Boolean { + UpdatePreferences(this).backgroundFinished("interrupted") check?.cancel() return UpdatePreferences(this).automatic } @@ -154,3 +188,27 @@ class UpdateCheckService : JobService() { super.onDestroy() } } + +internal suspend fun runBackgroundUpdateCheck( + context: Context, + source: UpdateSource, + check: suspend (UpdateSource) -> AppUpdate?, +): Boolean { + val prefs = UpdatePreferences(context) + prefs.backgroundStarted(source) + var retry = false + try { + val update = check(source) + prefs.detected(source, update) + prefs.backgroundFinished(if (update == null) "current" else "available") + if (update != null) UpdateNotifications.show(context, update) + else if (AppUpdates(context).source() == source) UpdateNotifications.cancel(context) + } catch (cancelled: CancellationException) { + prefs.backgroundFinished("interrupted") + throw cancelled + } catch (error: Exception) { + retry = error is java.io.IOException + prefs.backgroundFinished(if (retry) "network_error" else "error") + } + return retry && prefs.automatic && AppUpdates(context).source() == source +} diff --git a/app/src/main/java/life/andre/message487/UpdatesScreen.kt b/app/src/main/java/life/andre/message487/UpdatesScreen.kt index 5dd3508..a8888cf 100644 --- a/app/src/main/java/life/andre/message487/UpdatesScreen.kt +++ b/app/src/main/java/life/andre/message487/UpdatesScreen.kt @@ -53,6 +53,12 @@ internal class UpdatesViewModel @JvmOverloads constructor( private val checkUpdate: suspend (UpdateSource) -> AppUpdate? = updates::check, private val downloadUpdate: suspend (AppUpdate) -> File = updates::download, ) : AndroidViewModel(application) { + var backgroundTime by mutableStateOf(UpdatePreferences(application).lastBackgroundTime) + private set + var backgroundResult by mutableStateOf(UpdatePreferences(application).lastBackgroundResult) + private set + var backgroundSource by mutableStateOf(UpdatePreferences(application).lastBackgroundSource) + private set var automatic by mutableStateOf(UpdatePreferences(application).automatic) private set var source by mutableStateOf(updates.source()) @@ -92,6 +98,7 @@ internal class UpdatesViewModel @JvmOverloads constructor( apk = null runOperation { update = checkUpdate(selected) + UpdatePreferences(getApplication()).detected(selected, update) if (update == null) message = R.string.update_current } } @@ -109,7 +116,11 @@ internal class UpdatesViewModel @JvmOverloads constructor( } fun refresh() { - automatic = UpdatePreferences(getApplication()).automatic + val prefs = UpdatePreferences(getApplication()) + automatic = prefs.automatic + backgroundTime = prefs.lastBackgroundTime + backgroundResult = prefs.lastBackgroundResult + backgroundSource = prefs.lastBackgroundSource } fun reportOpenError() { message = R.string.update_open_error } @@ -151,6 +162,22 @@ internal fun UpdatesScreen(activity: Activity, model: UpdatesViewModel = viewMod verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(stringResource(R.string.update_installed, BuildConfig.VERSION_NAME)) Text(stringResource(R.string.update_manual_description)) + if (model.backgroundTime == 0L) Text(stringResource(R.string.update_background_never)) + else { + val time = java.text.DateFormat.getDateTimeInstance().format(java.util.Date(model.backgroundTime)) + Text(stringResource(R.string.update_background_time, time)) + val label = if (model.backgroundSource == UpdateSource.FDROID.name) R.string.update_fdroid else R.string.update_github + Text(stringResource(R.string.update_selected_source, stringResource(label))) + val result = when (model.backgroundResult) { + "running" -> R.string.update_background_running + "current" -> R.string.update_current + "available" -> R.string.update_background_available + "network_error" -> R.string.update_background_retry + "interrupted" -> R.string.update_background_interrupted + else -> R.string.update_background_error + } + Text(stringResource(result)) + } Text(stringResource(R.string.update_automatic)) Switch(checked = model.automatic, onCheckedChange = model::automatic, modifier = Modifier.semantics { contentDescription = activity.getString(R.string.update_automatic) }) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3be4aae..e976297 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -157,4 +157,12 @@ Отключить автопроверку Окно времени (± секунд) 0 — точное совпадение времени. Одинаковые новые сообщения внутри окна тоже пропускаются. + Напомнить через неделю + Фоновая проверка ещё не запускалась. Время запуска выбирает Android. + Последняя фоновая попытка: %1$s + Последняя попытка началась, но её результат ещё не записан. + Найдена новая версия. Показ уведомления зависит от системных разрешений и настроек напоминания. + Ошибка сети или временная ошибка сервера. Повтор запрошен через 30 минут с увеличением интервала; Android может отложить запуск. + Android прервал проверку. Запрошена повторная попытка. + Проверка не удалась. Повторите вручную; выбранный источник не изменён. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd66a59..031a887 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -157,4 +157,12 @@ Disable auto-checks Time window (± seconds) 0 compares exact timestamps. Identical new messages inside the window are also skipped. + Remind me in a week + No background check has run yet. Android chooses when to run checks. + Last background attempt: %1$s + The last attempt started but has not recorded a result yet. + A newer version was found. Notification delivery depends on system permissions and reminder settings. + A network or temporary server error occurred. A retry is requested after 30 minutes, with increasing delays; Android may defer it. + Android interrupted the check. A retry was requested. + The check failed. Retry manually; the selected source has not changed. diff --git a/app/src/test/java/life/andre/message487/AppUpdatesTest.kt b/app/src/test/java/life/andre/message487/AppUpdatesTest.kt index ebb5c54..c40d5d5 100644 --- a/app/src/test/java/life/andre/message487/AppUpdatesTest.kt +++ b/app/src/test/java/life/andre/message487/AppUpdatesTest.kt @@ -93,4 +93,15 @@ class AppUpdatesTest { assertArrayEquals(byteArrayOf(1, 2), readUpdateMetadata(ByteArrayInputStream(byteArrayOf(1, 2)))) assertThrows(IllegalArgumentException::class.java) { readUpdateMetadata(ByteArrayInputStream(ByteArray(1024 * 1024 + 1))) } } + @Test fun metadataRetriesOnlyNetworkAndTemporaryServerFailures() { + requireUpdateMetadataStatus(200) + for (status in listOf(429, 500, 502, 503, 599)) { + assertThrows(java.io.IOException::class.java) { requireUpdateMetadataStatus(status) } + } + val missing = assertThrows(UpdateException::class.java) { requireUpdateMetadataStatus(404) } + assertEquals(R.string.update_not_published, missing.textId) + for (status in listOf(201, 302, 400, 401, 403)) { + assertThrows(IllegalArgumentException::class.java) { requireUpdateMetadataStatus(status) } + } + } } diff --git a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt index 28cc94e..62bcf08 100644 --- a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt +++ b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt @@ -78,4 +78,85 @@ class UpdateNotificationsTest { UpdateNotifications.show(app, AppUpdate(UpdateSource.FDROID, "0.1.2")) assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) } + + @Test fun detectionSurvivesDeniedNotificationsAndReminderAndSkipPersist() { + AppUpdates(app).select(UpdateSource.GITHUB) + val update = AppUpdate(UpdateSource.GITHUB, "99.0.0") + shadowOf(app).denyPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + kotlinx.coroutines.runBlocking { assertFalse(runBackgroundUpdateCheck(app, update.source) { update }) } + val prefs = UpdatePreferences(app) + assertTrue(prefs.lastBackgroundTime > 0) + assertEquals("available", prefs.lastBackgroundResult) + assertEquals(update, prefs.pending()) + prefs.remindLater(update, 1000) + assertNull(UpdatePreferences(app).pending(1001)) + assertFalse(prefs.shouldNotify(update, 1001)) + assertEquals(update, prefs.pending(1000 + UPDATE_REMINDER_INTERVAL)) + prefs.skip(UpdatePreferences.key(update)) + assertNull(prefs.pending(1000 + UPDATE_REMINDER_INTERVAL * 2)) + prefs.detected(update.source, update.copy(version = "99.0.1")) + assertEquals("99.0.1", prefs.pending(1001)?.version) + AppUpdates(app).select(UpdateSource.FDROID) + assertNull(prefs.pending()) + } + + @Test fun backgroundFailureRetriesOnlyTransientErrorsAndKeepsDetectedVersion() { + AppUpdates(app).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(app) + val update = AppUpdate(UpdateSource.GITHUB, "99.0.0") + prefs.detected(update.source, update) + kotlinx.coroutines.runBlocking { + assertTrue(runBackgroundUpdateCheck(app, update.source) { throw java.io.IOException() }) + assertEquals("network_error", prefs.lastBackgroundResult) + assertEquals(update, prefs.pending()) + assertFalse(runBackgroundUpdateCheck(app, update.source) { throw IllegalArgumentException() }) + assertEquals("error", prefs.lastBackgroundResult) + assertFalse(runBackgroundUpdateCheck(app, update.source) { null }) + assertEquals("current", prefs.lastBackgroundResult) + assertNull(prefs.pending()) + assertFalse(runBackgroundUpdateCheck(app, update.source) { + prefs.automatic = false + throw java.io.IOException() + }) + } + assertEquals(UPDATE_RETRY_INTERVAL, run { + prefs.automatic = true + UpdateNotifications.schedule(app) + scheduler.getPendingJob(UPDATE_JOB_ID)!!.initialBackoffMillis + }) + } + + @Test fun staleBackgroundResultDoesNotReplaceDetectedSourceAndCancellationIsRecorded() { + AppUpdates(app).select(UpdateSource.FDROID) + val prefs = UpdatePreferences(app) + val current = AppUpdate(UpdateSource.FDROID, "99.0.0") + prefs.detected(current.source, current) + kotlinx.coroutines.runBlocking { + runBackgroundUpdateCheck(app, UpdateSource.GITHUB) { AppUpdate(it, "99.0.1") } + assertEquals(current, prefs.pending()) + try { + runBackgroundUpdateCheck(app, current.source) { throw kotlinx.coroutines.CancellationException() } + fail("Cancellation must propagate") + } catch (_: kotlinx.coroutines.CancellationException) { } + } + assertEquals("interrupted", prefs.lastBackgroundResult) + } + + @Test fun pendingRejectsInstalledMalformedAndStaleSourceVersions() { + AppUpdates(app).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(app) + for (version in listOf(BuildConfig.VERSION_NAME, "0.0.0", "invalid", "99.0.0-beta")) { + prefs.detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, version)) + assertNull(prefs.pending()) + } + val update = AppUpdate(UpdateSource.GITHUB, "99.0.0") + prefs.detected(update.source, update) + assertEquals(update, UpdatePreferences(app).pending()) + AppUpdates(app).select(UpdateSource.FDROID) + prefs.detected(UpdateSource.GITHUB, null) + prefs.detected(UpdateSource.FDROID, update.copy(source = UpdateSource.FDROID)) + prefs.detected(UpdateSource.GITHUB, update.copy(version = "100.0.0")) + assertEquals(UpdateSource.FDROID, prefs.pending()!!.source) + } + } diff --git a/app/src/test/java/life/andre/message487/UpdatesUiTest.kt b/app/src/test/java/life/andre/message487/UpdatesUiTest.kt index 4c74316..ef6ab53 100644 --- a/app/src/test/java/life/andre/message487/UpdatesUiTest.kt +++ b/app/src/test/java/life/andre/message487/UpdatesUiTest.kt @@ -64,4 +64,96 @@ class UpdatesUiTest { node(R.string.update_download).assertDoesNotExist() assertEquals(UpdateSource.FDROID, model.source) } + + @Test fun launchDialogOffersGithubNavigationAndSnoozesWithoutDownloading() { + AppUpdates(compose.activity).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(compose.activity) + prefs.detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, "99.0.0")) + var opened = 0 + compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) { opened++ } } } + node(R.string.update_skip).assertIsDisplayed() + node(R.string.update_remind_later).assertIsDisplayed() + node(R.string.update_notification_open).performClick() + compose.runOnIdle { + assertEquals(1, opened) + assertNull(prefs.pending()) + } + compose.onNode(isDialog()).assertDoesNotExist() + } + + @Test fun launchDialogFdroidOpensPackagePage() { + AppUpdates(compose.activity).select(UpdateSource.FDROID) + val prefs = UpdatePreferences(compose.activity) + val update = AppUpdate(UpdateSource.FDROID, "99.0.0") + prefs.detected(update.source, update) + compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) { error("F-Droid must not download") } } } + node(R.string.update_in_fdroid).performClick() + compose.runOnIdle { + assertEquals(FDROID_APP_URL, shadowOf(compose.activity).nextStartedActivity.data.toString()) + assertNull(prefs.pending()) + } + } + + @Test fun launchDialogSkipSuppressesVersionAcrossRecreation() { + AppUpdates(compose.activity).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(compose.activity) + prefs.detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, "99.0.0")) + compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) {} } } + node(R.string.update_skip).performClick() + compose.runOnIdle { assertNull(UpdatePreferences(compose.activity).pending(Long.MAX_VALUE)) } + compose.onNode(isDialog()).assertDoesNotExist() + } + + @Test fun backgroundDiagnosticIsVisibleAndManualCheckDoesNotOverwriteIt() { + AppUpdates(compose.activity).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(compose.activity) + prefs.detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, "99.0.0")) + prefs.backgroundStarted(UpdateSource.GITHUB, 1000) + prefs.backgroundFinished("network_error") + val model = UpdatesViewModel(compose.activity.application, checkUpdate = { null }) + compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + node(R.string.update_background_retry).performScrollTo().assertIsDisplayed() + node(R.string.update_check).performScrollTo().performClick() + node(R.string.update_current).assertExists() + compose.runOnIdle { + assertEquals(1000, prefs.lastBackgroundTime) + assertEquals("network_error", prefs.lastBackgroundResult) + assertNull(prefs.pending()) + } + } + + @Test fun notificationEntrySuppressesDuplicateDialog() { + AppUpdates(compose.activity).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(compose.activity) + prefs.detected(UpdateSource.GITHUB, AppUpdate(UpdateSource.GITHUB, "99.0.0")) + compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 1) {} } } + compose.onNode(isDialog()).assertDoesNotExist() + assertNotNull(prefs.pending()) + compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED) + compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED) + compose.onNode(isDialog()).assertExists() + } + + @Test fun remindLaterSuppressesBothDialogAndNotification() { + AppUpdates(compose.activity).select(UpdateSource.GITHUB) + val prefs = UpdatePreferences(compose.activity) + val update = AppUpdate(UpdateSource.GITHUB, "99.0.0") + prefs.detected(update.source, update) + compose.setContent { MessageTheme { UpdateAvailableDialog(compose.activity, 0) {} } } + node(R.string.update_remind_later).performClick() + compose.onNode(isDialog()).assertDoesNotExist() + assertNull(UpdatePreferences(compose.activity).pending()) + assertFalse(prefs.shouldNotify(update, System.currentTimeMillis())) + } + + @Test fun manualCheckStillShowsSkippedVersion() { + AppUpdates(compose.activity).select(UpdateSource.GITHUB) + val update = AppUpdate(UpdateSource.GITHUB, "99.0.0") + UpdatePreferences(compose.activity).skip(UpdatePreferences.key(update)) + val model = UpdatesViewModel(compose.activity.application, checkUpdate = { update }) + compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + node(R.string.update_check).performScrollTo().performClick() + compose.onNodeWithText(compose.activity.getString(R.string.update_available, update.version)).assertExists() + compose.runOnIdle { assertNull(UpdatePreferences(compose.activity).pending()) } + } } diff --git a/docs/en/apk-installation.md b/docs/en/apk-installation.md index 92a4711..dd9f58f 100644 --- a/docs/en/apk-installation.md +++ b/docs/en/apk-installation.md @@ -20,6 +20,28 @@ update one another; keep the existing app data and use the matching distribution If installation is blocked on Samsung Galaxy, see **Samsung Galaxy: Auto Blocker** below. +## Update reminders and background checks + +A detected newer version is saved even when system notifications are blocked. On app entry +or return to the foreground, Message487 offers a dialog for that version. **Update** opens +the GitHub update screen for a fresh metadata check; downloading still requires explicit +consent and installation is a separate action. **Update through F-Droid** opens the package +page in a compatible client or browser. + +**Remind me in a week**, including dismissing the dialog, postpones both the dialog and +notification for that version by a week. **Skip this version** suppresses both for that +version and source; manual checks can still show it. A successful check with no update, +a version already installed, or a different selected source prevents a stale dialog. +Opening the app only reads the saved detection; it does not start a network check by itself. + +The update screen shows the time, source and result of the latest background attempt, +including never-started, unfinished and interrupted attempts. Manual checks do not overwrite +this diagnostic. Network failures and HTTP 429/5xx request a retry after 30 minutes with +exponential backoff; Android may defer it. Other errors wait for the next daily check. +Disabling automatic checks or changing source suppresses retries from an in-flight request. +Only the attempt time, source and result category are stored here, without exception text, +URLs or credentials. A failed check does not discard the last detected version or change source. + ## Download and install 1. Open the project's [GitHub Releases](https://github.com/andre487/AndroidMessage487/releases) diff --git a/docs/en/testing.md b/docs/en/testing.md index 1d57853..d5d0f29 100644 --- a/docs/en/testing.md +++ b/docs/en/testing.md @@ -81,3 +81,8 @@ without checking out or executing PR code. A test/build failure without XML rema CI job, never a passing test report. Signed-release JVM results are published separately. For signed candidates, F-Droid and device checks, follow [Release testing](release-testing.md). + +Update regression tests cover the entry dialog, weekly snooze/skip, F-Droid routing, +GitHub navigation without automatic download, persisted background results, transient-error +retry policy, cancellation and stale-source handling. HTTP 429/5xx are retryable; 404 and +invalid responses are not. These JVM/Compose checks do not establish real JobScheduler timing. diff --git a/docs/ru/apk-installation.md b/docs/ru/apk-installation.md index 3898d9e..cda727e 100644 --- a/docs/ru/apk-installation.md +++ b/docs/ru/apk-installation.md @@ -22,6 +22,29 @@ F-Droid, включая альтернативные клиенты с подд На Samsung Galaxy при блокировке установки см. раздел **Samsung Galaxy: автоблокировка** ниже. +## Напоминания и фоновые проверки обновлений + +Найденная новая версия сохраняется даже при запрещённых системных уведомлениях. При входе +или возврате в приложение Message487 показывает диалог для этой версии. **Обновить** +открывает экран GitHub для повторной проверки метаданных; скачивание требует отдельного +согласия, а установка — отдельного действия. **Обновить через F-Droid** открывает карточку +приложения в совместимом клиенте или браузере. + +**Напомнить через неделю**, включая закрытие диалога, откладывает диалог и уведомление +для этой версии на неделю. **Пропустить эту версию** скрывает оба напоминания для данной +версии и источника; ручная проверка продолжает её показывать. Успешная проверка без +обновления, уже установленная версия или другой выбранный источник исключают устаревший +диалог. Сам вход в приложение читает сохранённый результат и не запускает сетевую проверку. + +Экран обновлений показывает время, источник и результат последней фоновой попытки, в том +числе ещё не запускавшуюся, незавершённую и прерванную проверку. Ручные проверки не +перезаписывают эту диагностику. Ошибки сети и HTTP 429/5xx запрашивают повтор через +30 минут с увеличением интервала; Android может отложить запуск. При других ошибках +следующая попытка идёт по суточному расписанию. Отключение автоматических проверок или +смена источника подавляют повтор запроса, уже выполнявшегося в фоне. Здесь сохраняются +только время, источник и категория результата, без текста исключений, URL и учётных +данных. Ошибка проверки не удаляет ранее найденную версию и не меняет источник. + ## Скачать и установить 1. Откройте на телефоне [GitHub Releases проекта](https://github.com/andre487/AndroidMessage487/releases). diff --git a/docs/ru/testing.md b/docs/ru/testing.md index 15298b0..b9fc88d 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -88,3 +88,9 @@ CI загружает отчёты и после ошибки тестов; св Полный регламент проверки подписанного кандидата, F-Droid и устройств — [Проверка релиза](release-testing.md). + +Регрессионные тесты обновлений покрывают диалог при входе, недельную отсрочку/пропуск, +переход в F-Droid, открытие экрана GitHub без автоматического скачивания, сохранённый +результат фоновой проверки, повторы временных ошибок, отмену и смену источника. +HTTP 429/5xx допускают повтор; 404 и неверные ответы — нет. JVM/Compose-проверки +не подтверждают фактическое время запуска JobScheduler.