Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/src/main/java/life/andre/message487/AppUpdates.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/life/andre/message487/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
80 changes: 80 additions & 0 deletions app/src/main/java/life/andre/message487/UpdateAvailableDialog.kt
Original file line number Diff line number Diff line change
@@ -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<AppUpdate?>(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)) }
},
)
}
76 changes: 67 additions & 9 deletions app/src/main/java/life/andre/message487/UpdateNotifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)

Expand All @@ -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())
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
29 changes: 28 additions & 1 deletion app/src/main/java/life/andre/message487/UpdatesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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 }
Expand Down Expand Up @@ -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) })
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,12 @@
<string name="update_disable">Отключить автопроверку</string>
<string name="deduplication_window">Окно времени (± секунд)</string>
<string name="deduplication_window_hint">0 — точное совпадение времени. Одинаковые новые сообщения внутри окна тоже пропускаются.</string>
<string name="update_remind_later">Напомнить через неделю</string>
<string name="update_background_never">Фоновая проверка ещё не запускалась. Время запуска выбирает Android.</string>
<string name="update_background_time">Последняя фоновая попытка: %1$s</string>
<string name="update_background_running">Последняя попытка началась, но её результат ещё не записан.</string>
<string name="update_background_available">Найдена новая версия. Показ уведомления зависит от системных разрешений и настроек напоминания.</string>
<string name="update_background_retry">Ошибка сети или временная ошибка сервера. Повтор запрошен через 30 минут с увеличением интервала; Android может отложить запуск.</string>
<string name="update_background_interrupted">Android прервал проверку. Запрошена повторная попытка.</string>
<string name="update_background_error">Проверка не удалась. Повторите вручную; выбранный источник не изменён.</string>
</resources>
8 changes: 8 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,12 @@
<string name="update_disable">Disable auto-checks</string>
<string name="deduplication_window">Time window (± seconds)</string>
<string name="deduplication_window_hint">0 compares exact timestamps. Identical new messages inside the window are also skipped.</string>
<string name="update_remind_later">Remind me in a week</string>
<string name="update_background_never">No background check has run yet. Android chooses when to run checks.</string>
<string name="update_background_time">Last background attempt: %1$s</string>
<string name="update_background_running">The last attempt started but has not recorded a result yet.</string>
<string name="update_background_available">A newer version was found. Notification delivery depends on system permissions and reminder settings.</string>
<string name="update_background_retry">A network or temporary server error occurred. A retry is requested after 30 minutes, with increasing delays; Android may defer it.</string>
<string name="update_background_interrupted">Android interrupted the check. A retry was requested.</string>
<string name="update_background_error">The check failed. Retry manually; the selected source has not changed.</string>
</resources>
11 changes: 11 additions & 0 deletions app/src/test/java/life/andre/message487/AppUpdatesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Loading
Loading