diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4b974edc..e7948564 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -92,7 +92,22 @@ jobs:
echo
echo '## Что изменилось'
echo
- if [ -n "$PREV" ]; then
+ # Раздел этой версии из CHANGELOG.md — он написан для пользователя,
+ # а не для того, кто читает код. Нет раздела (забыли дописать) —
+ # откатываемся на git-лог: релиз важнее красивых заметок.
+ CHANGES="$(awk -v ver="$VERSION" '
+ $0 ~ "^## \\[" ver "\\]" { inside = 1; next }
+ inside && /^## / { exit }
+ inside { print }
+ ' CHANGELOG.md 2>/dev/null | sed '/^[[:space:]]*$/d' || true)"
+
+ if [ -n "$CHANGES" ]; then
+ echo "$CHANGES"
+ if [ -n "$PREV" ]; then
+ echo
+ echo "[Все изменения с $PREV](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/$PREV...$GITHUB_REF_NAME)"
+ fi
+ elif [ -n "$PREV" ]; then
git log --pretty='- %s' "$PREV..$GITHUB_REF_NAME"
echo
echo "[Все изменения с $PREV](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/$PREV...$GITHUB_REF_NAME)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94028785..158c063a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,10 +15,13 @@
### Добавлено
-- **Проверка новых версий** — приложение раз в сутки спрашивает GitHub, не
- вышла ли версия новее, и при открытии, если давно не спрашивало. Пока
- проверка молчаливая: сообщение о найденной версии и обновление одним
- нажатием появятся в следующих выпусках.
+- **Самообновление** — приложение раз в сутки спрашивает GitHub, не вышла ли
+ версия новее, и сообщает об этом уведомлением. По нажатию «Обновить» само
+ скачивает файл, сверяет контрольную сумму и подпись и ставит новую версию
+ поверх старой: настройки, календарь и архив остаются на месте. Уведомление
+ можно пропустить — тогда о версии больше не напомнят.
+- **Экран «О программе»** — в настройках приложения: текущая версия, кнопка
+ «Проверить обновления» и переключатель автоматической проверки.
## [0.1.0] — 2026-08-11
diff --git a/README.md b/README.md
index 51cea4b9..3b94d190 100644
--- a/README.md
+++ b/README.md
@@ -37,11 +37,9 @@
[как именно](#обновления). Что изменилось от версии к версии —
[CHANGELOG.md](CHANGELOG.md).
-> Самообновление внутри приложения ещё пишется по
-> [плану](docs/updater/TASKS.md): проверку новых версий приложение уже делает,
-> а скачивание и установку одним нажатием — пока нет. До этого момента новая
-> версия ставится файлом со страницы релизов, поверх старой, с сохранением
-> данных.
+> Начиная с 0.1.1 приложение обновляет себя само: замечает новую версию,
+> сообщает уведомлением и по согласию ставит её одним нажатием. Версии старше
+> обновляются файлом со страницы релизов — поверх старой, данные сохраняются.
Сборка из исходников — Android Studio или `./gradlew assembleDebug`; что нужно
положить рядом, описано в [быстром старте](#быстрый-старт-разработка).
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5ed9a88d..2170dca2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/ru/greemlab/neiro/MainActivity.kt b/app/src/main/java/ru/greemlab/neiro/MainActivity.kt
index 94ebb5b4..4173bb88 100644
--- a/app/src/main/java/ru/greemlab/neiro/MainActivity.kt
+++ b/app/src/main/java/ru/greemlab/neiro/MainActivity.kt
@@ -44,14 +44,19 @@ class MainActivity : ComponentActivity() {
companion object {
const val EXTRA_OPEN_DATE = "open_date"
const val EXTRA_HIGHLIGHT_SLOT_KEY = "highlight_slot_key"
+
+ /** Открыть «О программе» — уведомление о новой версии (пакет `update`). */
+ const val EXTRA_OPEN_ABOUT = "open_about"
private const val STATE_OPEN_DATE = "neiro.state.open_date"
private const val STATE_HIGHLIGHT_SLOT_KEY = "neiro.state.highlight_slot_key"
private const val STATE_DEEP_LINK_VERSION = "neiro.state.deep_link_version"
+ private const val STATE_OPEN_ABOUT = "neiro.state.open_about"
}
private var openDate by mutableStateOf(null)
private var highlightSlotKey by mutableStateOf(null)
private var notificationDeepLinkVersion by mutableIntStateOf(0)
+ private var openAbout by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@@ -65,6 +70,7 @@ class MainActivity : ComponentActivity() {
openDate = savedInstanceState.getString(STATE_OPEN_DATE)
highlightSlotKey = savedInstanceState.getString(STATE_HIGHLIGHT_SLOT_KEY)
notificationDeepLinkVersion = savedInstanceState.getInt(STATE_DEEP_LINK_VERSION, 0)
+ openAbout = savedInstanceState.getBoolean(STATE_OPEN_ABOUT, false)
} else {
applyNotificationExtras(intent)
}
@@ -75,6 +81,7 @@ class MainActivity : ComponentActivity() {
openDateFromNotification = openDate,
highlightSlotKeyFromNotification = highlightSlotKey,
notificationDeepLinkVersion = deepLinkVersion,
+ openAboutFromNotification = openAbout,
)
RequestNotificationPermissionIfNeeded()
}
@@ -85,6 +92,7 @@ class MainActivity : ComponentActivity() {
openDate?.let { outState.putString(STATE_OPEN_DATE, it) }
highlightSlotKey?.let { outState.putString(STATE_HIGHLIGHT_SLOT_KEY, it) }
outState.putInt(STATE_DEEP_LINK_VERSION, notificationDeepLinkVersion)
+ outState.putBoolean(STATE_OPEN_ABOUT, openAbout)
}
override fun onNewIntent(intent: Intent) {
@@ -96,9 +104,13 @@ class MainActivity : ComponentActivity() {
private fun applyNotificationExtras(source: Intent?) {
val newOpenDate = source?.getStringExtra(EXTRA_OPEN_DATE)
val newHighlight = source?.getStringExtra(EXTRA_HIGHLIGHT_SLOT_KEY)
- val changed = newOpenDate != openDate || newHighlight != highlightSlotKey
+ val newOpenAbout = source?.getBooleanExtra(EXTRA_OPEN_ABOUT, false) == true
+ val changed = newOpenDate != openDate ||
+ newHighlight != highlightSlotKey ||
+ newOpenAbout != openAbout
openDate = newOpenDate
highlightSlotKey = newHighlight
+ openAbout = newOpenAbout
if (changed) notificationDeepLinkVersion++
}
}
@@ -151,6 +163,7 @@ private fun NeiroApp(
openDateFromNotification: String? = null,
highlightSlotKeyFromNotification: String? = null,
notificationDeepLinkVersion: Int = 0,
+ openAboutFromNotification: Boolean = false,
) {
val settingsViewModel: AppSettingsViewModel = viewModel()
val profileViewModel: ProfileViewModel = viewModel()
@@ -174,6 +187,7 @@ private fun NeiroApp(
openDateFromNotification = openDateFromNotification,
highlightSlotKeyFromNotification = highlightSlotKeyFromNotification,
notificationDeepLinkVersion = notificationDeepLinkVersion,
+ openAboutFromNotification = openAboutFromNotification,
)
}
}
diff --git a/app/src/main/java/ru/greemlab/neiro/ui/profile/ProfileContent.kt b/app/src/main/java/ru/greemlab/neiro/ui/profile/ProfileContent.kt
index 0d9b442a..d9ac460a 100644
--- a/app/src/main/java/ru/greemlab/neiro/ui/profile/ProfileContent.kt
+++ b/app/src/main/java/ru/greemlab/neiro/ui/profile/ProfileContent.kt
@@ -5,6 +5,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -77,6 +78,7 @@ fun ProfileContent(
onOpenSettings: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenYClients: () -> Unit = {},
+ onOpenAbout: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val profile by profileViewModel.userProfile.collectAsStateWithLifecycle()
@@ -134,6 +136,7 @@ fun ProfileContent(
onOpenSettings = onOpenSettings,
onOpenAppSettings = onOpenAppSettings,
onOpenYClients = onOpenYClients,
+ onOpenAbout = onOpenAbout,
onSyncNow = syncViewModel::syncAllThroughCurrentMonth,
onDevLogin = syncViewModel::devLogin,
onDevSync = syncViewModel::devSyncAll,
@@ -164,6 +167,7 @@ private fun ProfileContentImpl(
onOpenSettings: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenYClients: () -> Unit = {},
+ onOpenAbout: () -> Unit = {},
onSyncNow: () -> Unit = {},
onDevLogin: () -> Unit = {},
onDevSync: () -> Unit = {},
@@ -275,12 +279,13 @@ private fun ProfileContentImpl(
}
Spacer(modifier = Modifier.height(24.dp))
- BuildInfoFooter()
+ BuildInfoFooter(onClick = onOpenAbout)
}
}
@Composable
private fun BuildInfoFooter(
+ onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val buildTypeLabel = when (BuildConfig.BUILD_TYPE.lowercase(Locale.ROOT)) {
@@ -289,8 +294,12 @@ private fun BuildInfoFooter(
else -> stringResource(id = R.string.build_type_release)
}
+ // Номер сборки — самый короткий путь к «О программе»: он и так на виду в
+ // панели, и лезть за обновлением через настройки незачем.
Surface(
- modifier = modifier.fillMaxWidth(),
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
) {
diff --git a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt
index 3e8635e2..433ec342 100644
--- a/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt
+++ b/app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt
@@ -53,6 +53,7 @@ import ru.greemlab.neiro.ui.auth.AuthScreen
import ru.greemlab.neiro.ui.profile.ProfileContent
import ru.greemlab.neiro.ui.profile.ProfileViewModel
import ru.greemlab.neiro.ui.profile.SettingsScreen
+import ru.greemlab.neiro.ui.settings.AboutScreen
import ru.greemlab.neiro.ui.settings.AppSettingsScreen
import ru.greemlab.neiro.ui.settings.ProfitDisplayPreferences
import ru.greemlab.neiro.ui.settings.ProfitDisplaySettings
@@ -80,15 +81,22 @@ private sealed interface CalendarOverlay {
data object RegistrationPrompt : CalendarOverlay
data object ProfitDetails : CalendarOverlay
data object ProfitSettings : CalendarOverlay
+ data object About : CalendarOverlay
data object LessonsDetails : CalendarOverlay
data object DayDetails : CalendarOverlay
data object Notifications : CalendarOverlay
}
/** Куда вернуться по «Назад» с текущего overlay (иерархия настроек). */
-private fun CalendarOverlay.onSystemBack(yClientsReturnTo: CalendarOverlay): CalendarOverlay = when (this) {
+private fun CalendarOverlay.onSystemBack(
+ yClientsReturnTo: CalendarOverlay,
+ aboutReturnTo: CalendarOverlay,
+): CalendarOverlay = when (this) {
CalendarOverlay.NotificationSettings,
CalendarOverlay.ProfitSettings -> CalendarOverlay.AppSettings
+ // «О программе» открывается из двух мест: из настроек приложения и прямо из
+ // панели, нажатием на номер сборки. Возвращаться нужно туда, откуда пришли.
+ CalendarOverlay.About -> aboutReturnTo
CalendarOverlay.YClients -> yClientsReturnTo
else -> CalendarOverlay.None
}
@@ -119,6 +127,7 @@ fun CalendarScreen(
openDateFromNotification: String? = null,
highlightSlotKeyFromNotification: String? = null,
notificationDeepLinkVersion: Int = 0,
+ openAboutFromNotification: Boolean = false,
) {
val currentMonth by viewModel.currentMonth.collectAsStateWithLifecycle()
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
@@ -141,11 +150,14 @@ fun CalendarScreen(
var yClientsReturnOverlay by rememberSaveable(stateSaver = OverlaySaver) {
mutableStateOf(CalendarOverlay.None)
}
+ var aboutReturnOverlay by rememberSaveable(stateSaver = OverlaySaver) {
+ mutableStateOf(CalendarOverlay.AppSettings)
+ }
/** После закрытия overlay, открытый из drawer, снова показать боковую панель. */
var returnToDrawerOnOverlayClose by rememberSaveable { mutableStateOf(false) }
fun applyOverlayBackNavigation() {
- val next = overlay.onSystemBack(yClientsReturnOverlay)
+ val next = overlay.onSystemBack(yClientsReturnOverlay, aboutReturnOverlay)
overlay = next
if (next is CalendarOverlay.None && returnToDrawerOnOverlayClose) {
returnToDrawerOnOverlayClose = false
@@ -184,6 +196,18 @@ fun CalendarScreen(
}
}
+ // Уведомление о новой версии: открываем «О программе» сразу, минуя
+ // настройки — человек пришёл именно за кнопкой обновления.
+ LaunchedEffect(openAboutFromNotification, notificationDeepLinkVersion) {
+ if (!openAboutFromNotification) return@LaunchedEffect
+ if (notificationDeepLinkVersion == lastHandledDeepLinkVersion) return@LaunchedEffect
+ // Пришли из уведомления — «Назад» ведёт в календарь: в настройках
+ // человек не был, возвращать его туда неоткуда.
+ aboutReturnOverlay = CalendarOverlay.None
+ overlay = CalendarOverlay.About
+ lastHandledDeepLinkVersion = notificationDeepLinkVersion
+ }
+
LaunchedEffect(highlightSlotKey) {
if (highlightSlotKey == null) return@LaunchedEffect
delay(3_500)
@@ -314,6 +338,14 @@ fun CalendarScreen(
yClientsReturnOverlay = CalendarOverlay.None
overlay = CalendarOverlay.YClients
},
+ onOpenAbout = {
+ scope.launch { drawerState.close() }
+ returnToDrawerOnOverlayClose = true
+ // Пришли из панели — «Назад» вернёт в неё же,
+ // а не в настройки, куда человек не заходил.
+ aboutReturnOverlay = CalendarOverlay.None
+ overlay = CalendarOverlay.About
+ },
)
}
}
@@ -396,6 +428,16 @@ fun CalendarScreen(
onBack = ::applyOverlayBackNavigation,
onOpenNotificationSettings = { overlay = CalendarOverlay.NotificationSettings },
onOpenProfitSettings = { overlay = CalendarOverlay.ProfitSettings },
+ onOpenAbout = {
+ aboutReturnOverlay = CalendarOverlay.AppSettings
+ overlay = CalendarOverlay.About
+ },
+ )
+ }
+
+ if (overlay is CalendarOverlay.About) {
+ AboutScreen(
+ onBack = ::applyOverlayBackNavigation,
)
}
@@ -584,6 +626,7 @@ private val OverlaySaver = Saver(
CalendarOverlay.RegistrationPrompt -> "registration"
CalendarOverlay.ProfitDetails -> "profit_details"
CalendarOverlay.ProfitSettings -> "profit_settings"
+ CalendarOverlay.About -> "about"
CalendarOverlay.LessonsDetails -> "lessons"
CalendarOverlay.DayDetails -> "day"
CalendarOverlay.Notifications -> "notifications"
@@ -599,6 +642,7 @@ private val OverlaySaver = Saver(
"profit" -> CalendarOverlay.ProfitDetails
"profit_details" -> CalendarOverlay.ProfitDetails
"profit_settings" -> CalendarOverlay.ProfitSettings
+ "about" -> CalendarOverlay.About
"lessons" -> CalendarOverlay.LessonsDetails
"day" -> CalendarOverlay.DayDetails
"notifications" -> CalendarOverlay.Notifications
diff --git a/app/src/main/java/ru/greemlab/neiro/ui/settings/AboutScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/settings/AboutScreen.kt
new file mode 100644
index 00000000..6ec0aa0f
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/ui/settings/AboutScreen.kt
@@ -0,0 +1,464 @@
+package ru.greemlab.neiro.ui.settings
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.CloudDownload
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import ru.greemlab.neiro.R
+import ru.greemlab.neiro.update.UpdateBlockReason
+import ru.greemlab.neiro.update.UpdateFailure
+import ru.greemlab.neiro.update.UpdateInfo
+import ru.greemlab.neiro.update.UpdateState
+import ru.greemlab.neiro.update.UpdateViewModel
+import ru.greemlab.neiro.update.isBusy
+import java.io.File
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+/**
+ * «О программе» — здесь живёт кнопка «Проверить обновления». До этого экрана
+ * обновление существует только в фоне и в уведомлении.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AboutScreen(
+ onBack: () -> Unit,
+ viewModel: UpdateViewModel = viewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val autoCheck by viewModel.autoCheckEnabled.collectAsStateWithLifecycle()
+ val lastCheckAt by viewModel.lastCheckAt.collectAsStateWithLifecycle()
+ val justUpdatedTo by viewModel.justUpdatedTo.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ fun openUrl(url: String) {
+ if (url.isBlank()) return
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ // Устройство без браузера — редкость, но падать из-за неё экран не должен.
+ try {
+ context.startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ Unit
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.about_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Назад")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ justUpdatedTo?.let { version ->
+ SettingsGroupCard {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.about_just_updated, version),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ TextButton(onClick = viewModel::dismissJustUpdated) {
+ Text(stringResource(R.string.about_dismiss_error))
+ }
+ }
+ }
+ }
+
+ SettingsSection(title = stringResource(R.string.about_section_version)) {
+ SettingsGroupCard {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = "Neiro ${viewModel.versionName}",
+ style = MaterialTheme.typography.titleMedium,
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(R.string.about_build, viewModel.versionCode),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
+ )
+ }
+ }
+
+ when (val current = state) {
+ is UpdateState.Blocked -> BlockedCard(current.why)
+
+ else -> UpdateCard(
+ state = current,
+ autoCheck = autoCheck,
+ lastCheckAt = lastCheckAt,
+ onCheck = { viewModel.check() },
+ onAutoCheckChange = viewModel::setAutoCheckEnabled,
+ onDownload = { info -> viewModel.downloadAndInstall(info) },
+ onInstall = { info, apk -> viewModel.install(info, apk) },
+ onSkip = { info -> viewModel.skip(info) },
+ onDismissFailure = viewModel::dismissFailure,
+ onOpenRelease = { info -> openUrl(info.releaseUrl) },
+ onOpenInstallSettings = {
+ try {
+ context.startActivity(viewModel.installPermissionIntent())
+ } catch (_: ActivityNotFoundException) {
+ Unit
+ }
+ },
+ needsInstallPermission = viewModel.needsInstallPermission(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun UpdateCard(
+ state: UpdateState,
+ autoCheck: Boolean,
+ lastCheckAt: Long,
+ onCheck: () -> Unit,
+ onAutoCheckChange: (Boolean) -> Unit,
+ onDownload: (UpdateInfo) -> Unit,
+ onInstall: (UpdateInfo, File) -> Unit,
+ onSkip: (UpdateInfo) -> Unit,
+ onDismissFailure: () -> Unit,
+ onOpenRelease: (UpdateInfo) -> Unit,
+ onOpenInstallSettings: () -> Unit,
+ needsInstallPermission: Boolean,
+) {
+ SettingsSection(title = stringResource(R.string.about_section_updates)) {
+ SettingsGroupCard {
+ SettingsNavigationRow(
+ title = stringResource(R.string.about_check_updates),
+ subtitle = lastCheckSubtitle(state, lastCheckAt),
+ icon = Icons.Rounded.Refresh,
+ enabled = !state.isBusy,
+ onClick = onCheck,
+ )
+ SettingsSwitchRow(
+ title = stringResource(R.string.about_auto_check),
+ subtitle = stringResource(R.string.about_auto_check_hint),
+ checked = autoCheck,
+ onCheckedChange = onAutoCheckChange,
+ showDivider = true,
+ )
+ }
+
+ when (state) {
+ is UpdateState.Available -> AvailableBlock(
+ info = state.info,
+ onDownload = onDownload,
+ onSkip = onSkip,
+ onOpenRelease = onOpenRelease,
+ needsInstallPermission = needsInstallPermission,
+ onOpenInstallSettings = onOpenInstallSettings,
+ )
+
+ is UpdateState.Downloading -> ProgressBlock(
+ text = stringResource(R.string.about_downloading, state.percent),
+ percent = state.percent,
+ )
+
+ is UpdateState.Verifying -> ProgressBlock(
+ text = stringResource(R.string.about_verifying),
+ percent = null,
+ )
+
+ // Скачали, но не поставили — обычно потому, что закрыли системный
+ // диалог. Качать заново незачем: файл на месте и уже проверен.
+ is UpdateState.ReadyToInstall -> ReadyToInstallBlock(
+ versionName = state.info.version.versionName,
+ needsInstallPermission = needsInstallPermission,
+ onInstall = { onInstall(state.info, state.apk) },
+ onOpenInstallSettings = onOpenInstallSettings,
+ )
+
+ is UpdateState.Installing -> ProgressBlock(
+ text = stringResource(R.string.about_installing),
+ percent = null,
+ )
+
+ is UpdateState.AwaitingConfirmation -> HintCard(
+ text = stringResource(R.string.about_awaiting_confirmation),
+ )
+
+ is UpdateState.Failed -> FailureBlock(
+ failure = state.reason,
+ onDismiss = onDismissFailure,
+ )
+
+ is UpdateState.UpToDate -> HintCard(text = stringResource(R.string.about_up_to_date))
+
+ else -> Unit
+ }
+ }
+}
+
+@Composable
+private fun AvailableBlock(
+ info: UpdateInfo,
+ onDownload: (UpdateInfo) -> Unit,
+ onSkip: (UpdateInfo) -> Unit,
+ onOpenRelease: (UpdateInfo) -> Unit,
+ needsInstallPermission: Boolean,
+ onOpenInstallSettings: () -> Unit,
+) {
+ SettingsGroupCard {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.about_available, info.version.versionName),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ if (info.notes.isNotBlank()) {
+ Text(
+ text = stringResource(R.string.about_whats_new),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ // Markdown из GitHub рендерить нечем и не нужно: показываем
+ // как есть, а за полным текстом — ссылка на релиз.
+ text = info.notes,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = NOTES_MAX_LINES,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ if (needsInstallPermission) {
+ Text(
+ text = stringResource(R.string.about_install_permission_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedButton(
+ onClick = onOpenInstallSettings,
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(12.dp),
+ ) {
+ Text(stringResource(R.string.about_install_permission_action))
+ }
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = { onDownload(info) }, modifier = Modifier.weight(1f)) {
+ Icon(Icons.Rounded.CloudDownload, contentDescription = null)
+ Text(
+ text = stringResource(R.string.about_update),
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ OutlinedButton(onClick = { onSkip(info) }, modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.about_skip))
+ }
+ }
+
+ TextButton(onClick = { onOpenRelease(info) }) {
+ Text(stringResource(R.string.about_open_github))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReadyToInstallBlock(
+ versionName: String,
+ needsInstallPermission: Boolean,
+ onInstall: () -> Unit,
+ onOpenInstallSettings: () -> Unit,
+) {
+ SettingsGroupCard {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.about_ready_to_install, versionName),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (needsInstallPermission) {
+ Text(
+ text = stringResource(R.string.about_install_permission_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedButton(
+ onClick = onOpenInstallSettings,
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(12.dp),
+ ) {
+ Text(stringResource(R.string.about_install_permission_action))
+ }
+ }
+ Button(onClick = onInstall, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.about_install_now))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProgressBlock(text: String, percent: Int?) {
+ SettingsGroupCard {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(text = text, style = MaterialTheme.typography.bodyMedium)
+ if (percent == null) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ } else {
+ LinearProgressIndicator(
+ progress = { percent / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun FailureBlock(failure: UpdateFailure, onDismiss: () -> Unit) {
+ SettingsGroupCard {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(failureTextRes(failure)),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.about_dismiss_error))
+ }
+ }
+ }
+}
+
+@Composable
+private fun HintCard(text: String) {
+ SettingsGroupCard {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ )
+ }
+}
+
+@Composable
+private fun BlockedCard(why: UpdateBlockReason) {
+ val text = when (why) {
+ UpdateBlockReason.NotReleaseBuild -> stringResource(R.string.about_blocked_debug)
+ UpdateBlockReason.InstalledFromRuStore -> stringResource(R.string.about_blocked_rustore)
+ UpdateBlockReason.InstalledFromPlay -> stringResource(R.string.about_blocked_play)
+ }
+ SettingsSection(title = stringResource(R.string.about_section_updates)) {
+ HintCard(text = text)
+ }
+}
+
+@Composable
+private fun lastCheckSubtitle(state: UpdateState, lastCheckAt: Long): String = when {
+ state is UpdateState.Checking -> stringResource(R.string.about_checking)
+ lastCheckAt <= 0L -> stringResource(R.string.about_never_checked)
+ else -> stringResource(R.string.about_last_check, formatCheckedAt(lastCheckAt))
+}
+
+private fun formatCheckedAt(epochMillis: Long): String {
+ val zone = ZoneId.systemDefault()
+ val moment = Instant.ofEpochMilli(epochMillis).atZone(zone)
+ val time = moment.toLocalTime().format(TIME_FORMAT)
+ return if (moment.toLocalDate() == LocalDate.now(zone)) {
+ "сегодня в $time"
+ } else {
+ "${moment.toLocalDate().format(DATE_FORMAT)} в $time"
+ }
+}
+
+private fun failureTextRes(failure: UpdateFailure): Int = when (failure) {
+ UpdateFailure.NoNetwork -> R.string.about_error_no_network
+ UpdateFailure.RateLimited -> R.string.about_error_rate_limited
+ UpdateFailure.NoRelease -> R.string.about_error_no_release
+ UpdateFailure.MalformedRelease -> R.string.about_error_malformed
+ UpdateFailure.DownloadFailed -> R.string.about_error_download
+ UpdateFailure.ChecksumMismatch -> R.string.about_error_checksum
+ UpdateFailure.SignatureMismatch -> R.string.about_error_signature
+ UpdateFailure.NoSpace -> R.string.about_error_no_space
+ UpdateFailure.InstallRejected -> R.string.about_error_install_rejected
+ UpdateFailure.InstallFailed -> R.string.about_error_install_failed
+}
+
+private const val NOTES_MAX_LINES = 8
+private val TIME_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
+private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
diff --git a/app/src/main/java/ru/greemlab/neiro/ui/settings/AppSettingsScreen.kt b/app/src/main/java/ru/greemlab/neiro/ui/settings/AppSettingsScreen.kt
index fd208b39..7a7e2559 100644
--- a/app/src/main/java/ru/greemlab/neiro/ui/settings/AppSettingsScreen.kt
+++ b/app/src/main/java/ru/greemlab/neiro/ui/settings/AppSettingsScreen.kt
@@ -15,6 +15,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.DarkMode
+import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.Notifications
@@ -32,6 +33,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -43,7 +45,9 @@ import ru.greemlab.neiro.data.ImportResult
import ru.greemlab.neiro.data.archiveExportSuggestedFileName
import ru.greemlab.neiro.data.THEME_DARK
import ru.greemlab.neiro.data.THEME_LIGHT
+import ru.greemlab.neiro.BuildConfig
import ru.greemlab.neiro.data.THEME_SYSTEM
+import ru.greemlab.neiro.update.UpdateNotifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -51,10 +55,14 @@ fun AppSettingsScreen(
onBack: () -> Unit,
onOpenNotificationSettings: () -> Unit = {},
onOpenProfitSettings: () -> Unit = {},
+ onOpenAbout: () -> Unit = {},
viewModel: AppSettingsViewModel = viewModel(),
) {
val theme by viewModel.theme.collectAsStateWithLifecycle()
val context = LocalContext.current
+ // Известная из прошлой проверки версия — только чтобы подписать пункт
+ // «О программе». В сеть отсюда не ходим.
+ val newerVersion = remember { UpdateNotifier.knownNewerVersion(context) }
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsStateWithLifecycle()
val notificationsEnabled by viewModel.sessionNotificationsEnabled.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
@@ -171,6 +179,24 @@ fun AppSettingsScreen(
}
}
+ SettingsSection(title = "О программе") {
+ SettingsGroupCard {
+ SettingsNavigationRow(
+ title = stringResource(R.string.settings_about_row),
+ subtitle = if (newerVersion != null) {
+ stringResource(
+ R.string.settings_about_row_update_available,
+ newerVersion.versionName,
+ )
+ } else {
+ stringResource(R.string.settings_about_row_subtitle, BuildConfig.VERSION_NAME)
+ },
+ icon = Icons.Rounded.Info,
+ onClick = onOpenAbout,
+ )
+ }
+ }
+
SettingsSection(title = "Архив") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
diff --git a/app/src/main/java/ru/greemlab/neiro/update/ApkInstaller.kt b/app/src/main/java/ru/greemlab/neiro/update/ApkInstaller.kt
new file mode 100644
index 00000000..1b3a7cc7
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/ApkInstaller.kt
@@ -0,0 +1,132 @@
+package ru.greemlab.neiro.update
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+
+/**
+ * Установка скачанного APK через `PackageInstaller` Session API.
+ *
+ * Не `Intent(ACTION_VIEW)` с `FileProvider`: этому пути не нужен внешний
+ * каталог, он возвращает внятный статус установки, и только он умеет ставить
+ * самообновление без второго системного окна.
+ *
+ * Помнить о главном: `STATUS_SUCCESS` означает, что процесс сейчас убьют и
+ * заменят. Поэтому всё, что нужно запомнить о факте обновления, пишется **до**
+ * `commit` и через `commit()`, а не `apply()` — асинхронная запись не успеет
+ * (RISKS.md § Установка убивает процесс).
+ */
+object ApkInstaller {
+
+ private const val TAG = "ApkInstaller"
+
+ /** Разрешена ли приложению установка пакетов. Ниже API 26 разрешение не спрашивают. */
+ fun canInstall(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true
+ return context.applicationContext.packageManager.canRequestPackageInstalls()
+ }
+
+ /**
+ * Экран системных настроек «Установка неизвестных приложений» для Neiro.
+ * Обойти это нельзя никаким способом, и пытаться не нужно — задача экрана
+ * объяснить одной фразой, зачем это, и открыть нужный переключатель сразу.
+ */
+ fun unknownSourcesSettingsIntent(context: Context): Intent =
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
+ .setData(Uri.parse("package:${context.applicationContext.packageName}"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ /**
+ * Отдаёт файл системе. Дальше всё решает установщик: результат придёт
+ * широковещательно в [UpdateInstallReceiver].
+ *
+ * @return причина отказа, если до установщика дело не дошло; null — сессия
+ * отдана системе, ждём ответа.
+ */
+ suspend fun install(
+ context: Context,
+ apk: File,
+ versionCode: Int,
+ ): UpdateFailure? = withContext(Dispatchers.IO) {
+ val appContext = context.applicationContext
+
+ if (!apk.isFile || apk.length() == 0L) return@withContext UpdateFailure.DownloadFailed
+ if (!canInstall(appContext)) return@withContext UpdateFailure.InstallRejected
+
+ // Пишем ДО commit и синхронно: после успешной установки процесс будет
+ // убит, и этот код может не выполниться вовсе.
+ UpdatePreferences.get(appContext).recordInstallAttempt(versionCode)
+
+ val installer = appContext.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
+ setAppPackageName(appContext.packageName)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // Android 12+ разрешает приложению обновить само себя без
+ // системного окна, если подпись совпадает и разрешение уже
+ // выдано. Это не обход защиты: система проверяет всё то же
+ // самое, просто не спрашивает второй раз о том, на что
+ // пользователь уже согласился. Не выполнилось условие —
+ // вернётся STATUS_PENDING_USER_ACTION и покажем подтверждение.
+ setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
+ }
+ // setRequestUpdateOwnership намеренно НЕ включаем: заявив владение
+ // обновлениями, мы заставили бы будущий RuStore показывать
+ // предупреждение при своей же установке.
+ }
+
+ var sessionId = -1
+ try {
+ sessionId = installer.createSession(params)
+ installer.openSession(sessionId).use { session ->
+ session.openWrite(APK_ENTRY_NAME, 0, apk.length()).use { output ->
+ apk.inputStream().use { input -> input.copyTo(output, BUFFER_SIZE) }
+ session.fsync(output)
+ }
+ session.commit(statusIntent(appContext, versionCode).intentSender)
+ }
+ Log.i(TAG, "Сессия установки $sessionId отдана системе")
+ null
+ } catch (e: IOException) {
+ Log.w(TAG, "Установка не началась: ${e.message}")
+ if (sessionId >= 0) runCatching { installer.abandonSession(sessionId) }
+ UpdatePreferences.get(appContext).clearInstallAttempt()
+ UpdateFailure.InstallFailed
+ } catch (e: SecurityException) {
+ Log.w(TAG, "Установщик отказал: ${e.message}")
+ if (sessionId >= 0) runCatching { installer.abandonSession(sessionId) }
+ UpdatePreferences.get(appContext).clearInstallAttempt()
+ UpdateFailure.InstallRejected
+ }
+ }
+
+ /**
+ * `FLAG_MUTABLE` обязателен: система дописывает в интент свои extras
+ * (статус, сообщение, интент подтверждения). `setPackage` — чтобы
+ * широковещалку не поймал никто чужой.
+ */
+ private fun statusIntent(context: Context, versionCode: Int): PendingIntent {
+ val intent = Intent(UpdateInstallReceiver.ACTION_INSTALL_STATUS)
+ .setPackage(context.packageName)
+ .putExtra(UpdateInstallReceiver.EXTRA_VERSION_CODE, versionCode)
+
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ return PendingIntent.getBroadcast(context, REQUEST_STATUS, intent, flags)
+ }
+
+ private const val APK_ENTRY_NAME = "neiro_update.apk"
+ private const val BUFFER_SIZE = 64 * 1024
+ private const val REQUEST_STATUS = 4_300
+}
diff --git a/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt b/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt
index 82ac8820..de4f017c 100644
--- a/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt
+++ b/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt
@@ -29,6 +29,19 @@ data class ReleaseVersion(
companion object {
private val TAG_REGEX = Regex("""^v(\d+)\.(\d+)\.(\d+)$""")
+ /**
+ * Обратно из `versionCode` — для значка «доступна версия», когда самого
+ * релиза под рукой нет, а в настройках лежит только число.
+ */
+ fun fromVersionCode(versionCode: Int): ReleaseVersion? {
+ if (versionCode <= 0) return null
+ return ReleaseVersion(
+ major = versionCode / 10_000,
+ minor = (versionCode / 100) % 100,
+ patch = versionCode % 100,
+ )
+ }
+
/** null — тег не нашей схемы; такой релиз молча игнорируем. */
fun parseTag(tag: String): ReleaseVersion? {
val m = TAG_REGEX.matchEntire(tag.trim()) ?: return null
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt
index 8bd91c03..3632750e 100644
--- a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import ru.greemlab.neiro.BuildConfig
import java.util.concurrent.TimeUnit
/**
@@ -66,6 +67,8 @@ object UpdateCheckCoordinator {
}
val preferences = UpdatePreferences.get(appContext)
+ cleanupInstalledLeftovers(appContext, preferences)
+
if (preferences.isAutoCheckEnabled) {
schedulePeriodic(appContext)
} else {
@@ -97,18 +100,21 @@ object UpdateCheckCoordinator {
* @param force ручная проверка: суточный троттлинг игнорируется.
*/
suspend fun checkNow(context: Context, force: Boolean = false): UpdateStatus {
- val status = UpdateChecker.create(context).check(force)
+ val appContext = context.applicationContext
+ val status = UpdateChecker.create(appContext).check(force)
when (status) {
- is UpdateStatus.Available ->
+ is UpdateStatus.Available -> {
Log.i(TAG, "Есть обновление: ${status.info.version.versionName}")
+ // Правило «об одной версии говорим один раз» и проверка
+ // «пропустили» живут внутри UpdateNotifier — здесь не дублируем.
+ UpdateNotifier.notifyIfNeeded(appContext, status.info)
+ }
is UpdateStatus.UpToDate -> Log.i(TAG, "Обновлений нет")
is UpdateStatus.Throttled -> Log.i(TAG, "Проверяли меньше суток назад, GitHub не трогаем")
is UpdateStatus.Blocked -> Log.i(TAG, "Самообновление выключено: ${status.why}")
is UpdateStatus.Failed -> Log.w(TAG, "Проверка не удалась: ${status.failure}")
}
- // Показ уведомления о найденной версии — этап 5: там же живёт правило
- // «об одной версии говорим один раз».
return status
}
@@ -123,6 +129,24 @@ object UpdateCheckCoordinator {
}
}
+ /**
+ * Пятнадцать мегабайт от версии, которая уже стоит (или устарела), лежат в
+ * кэше мёртвым грузом. Чистим при старте, не дожидаясь, пока человек зайдёт
+ * на экран «О программе».
+ *
+ * Отметку `updated_from_version_code` здесь не трогаем: её читает и стирает
+ * экран, чтобы показать «Обновлено до 0.1.2» ровно один раз.
+ */
+ private fun cleanupInstalledLeftovers(context: Context, preferences: UpdatePreferences) {
+ val pending = preferences.pendingVersionCode
+ if (preferences.pendingApkPath == null && pending == 0) return
+ if (pending > BuildConfig.VERSION_CODE) return
+
+ UpdateDownloader.clearDownloads(context)
+ preferences.clearPendingApk()
+ Log.i(TAG, "Убрал скачанный APK версии $pending — она уже не новее установленной")
+ }
+
private fun schedulePeriodic(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateDownloader.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateDownloader.kt
new file mode 100644
index 00000000..f80e0ae3
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateDownloader.kt
@@ -0,0 +1,146 @@
+package ru.greemlab.neiro.update
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.IOException
+import kotlin.coroutines.coroutineContext
+
+/** Чем закончилась загрузка файла. */
+sealed interface DownloadOutcome {
+ data class Success(val apk: File) : DownloadOutcome
+ data class Failed(val failure: UpdateFailure) : DownloadOutcome
+}
+
+/**
+ * Скачивание APK обновления.
+ *
+ * Файл кладём в `cacheDir/updates` — приватный каталог: снаружи его не видно,
+ * а при нехватке места система вычистит его сама. Перед каждой попыткой каталог
+ * очищается: два APK по 15 МБ на забитом телефоне — лишняя причина отказа.
+ *
+ * Запускается **только** по действию пользователя. Автоматически не качаем
+ * никогда: пятнадцать мегабайт по мобильному интернету без спроса не прощают.
+ */
+class UpdateDownloader(
+ private val client: OkHttpClient = UpdateClient.getHttpClient(),
+) {
+
+ /**
+ * @param onProgress проценты, не чаще раза в [PROGRESS_INTERVAL_MS] — иначе
+ * Compose дёргался бы на каждый прочитанный буфер.
+ */
+ suspend fun download(
+ context: Context,
+ info: UpdateInfo,
+ onProgress: (Int) -> Unit = {},
+ ): DownloadOutcome = withContext(Dispatchers.IO) {
+ val appContext = context.applicationContext
+ val dir = prepareDirectory(appContext)
+ ?: return@withContext DownloadOutcome.Failed(UpdateFailure.DownloadFailed)
+
+ // Запас сверх размера APK: система пишет файл целиком, и упереться в
+ // ноль на последнем мегабайте — худший момент для отказа.
+ val required = info.apkSizeBytes + (info.apkSizeBytes / 5)
+ if (info.apkSizeBytes > 0 && dir.usableSpace < required) {
+ Log.w(TAG, "Мало места: нужно $required, свободно ${dir.usableSpace}")
+ return@withContext DownloadOutcome.Failed(UpdateFailure.NoSpace)
+ }
+
+ val target = File(dir, info.apkName.ifBlank { "neiro-${info.version.versionName}.apk" })
+ try {
+ downloadTo(info.apkUrl, target, info.apkSizeBytes, onProgress)
+ DownloadOutcome.Success(target)
+ } catch (e: IOException) {
+ // Оборванная закачка оставляет огрызок файла — он бесполезен и
+ // мешает следующей попытке пройти проверку суммы.
+ target.delete()
+ Log.w(TAG, "Загрузка не удалась: ${e.message}")
+ DownloadOutcome.Failed(UpdateFailure.DownloadFailed)
+ } catch (e: Throwable) {
+ target.delete()
+ throw e
+ }
+ }
+
+ /** Текстовый ассет с суммами — он маленький, читаем целиком в память. */
+ suspend fun downloadChecksums(url: String): String? = withContext(Dispatchers.IO) {
+ if (url.isBlank()) return@withContext null
+ try {
+ client.newCall(Request.Builder().url(url).build()).execute().use { response ->
+ if (!response.isSuccessful) return@withContext null
+ response.body?.string()
+ }
+ } catch (e: IOException) {
+ Log.w(TAG, "SHA256SUMS.txt не скачался: ${e.message}")
+ null
+ }
+ }
+
+ private suspend fun downloadTo(
+ url: String,
+ target: File,
+ expectedSize: Long,
+ onProgress: (Int) -> Unit,
+ ) {
+ if (url.isBlank()) throw IOException("У ассета нет ссылки на скачивание")
+
+ client.newCall(Request.Builder().url(url).build()).execute().use { response ->
+ if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
+ val body = response.body ?: throw IOException("Пустой ответ")
+ val total = if (expectedSize > 0) expectedSize else body.contentLength()
+
+ body.byteStream().use { input ->
+ target.outputStream().use { output ->
+ val buffer = ByteArray(BUFFER_SIZE)
+ var downloaded = 0L
+ var lastReport = 0L
+
+ while (true) {
+ // Пользователь ушёл с экрана — закачку прекращаем, а не
+ // дотягиваем 15 МБ «на всякий случай».
+ coroutineContext.ensureActive()
+
+ val read = input.read(buffer)
+ if (read < 0) break
+ output.write(buffer, 0, read)
+ downloaded += read
+
+ val nowMs = System.currentTimeMillis()
+ if (total > 0 && nowMs - lastReport >= PROGRESS_INTERVAL_MS) {
+ lastReport = nowMs
+ onProgress(((downloaded * 100) / total).toInt().coerceIn(0, 100))
+ }
+ }
+ output.fd.sync()
+ }
+ }
+ onProgress(100)
+ }
+ }
+
+ /** @return каталог загрузок, пустой; null — создать не вышло. */
+ private fun prepareDirectory(context: Context): File? {
+ val dir = File(context.cacheDir, UpdateConfig.DOWNLOAD_DIR)
+ dir.listFiles()?.forEach { it.delete() }
+ if (!dir.exists() && !dir.mkdirs()) return null
+ return dir.takeIf { it.isDirectory }
+ }
+
+ companion object {
+ private const val TAG = "UpdateDownloader"
+ private const val BUFFER_SIZE = 64 * 1024
+ private const val PROGRESS_INTERVAL_MS = 200L
+
+ /** Удалить скачанное — после установки, отказа или неудачной проверки. */
+ fun clearDownloads(context: Context) {
+ val dir = File(context.applicationContext.cacheDir, UpdateConfig.DOWNLOAD_DIR)
+ dir.listFiles()?.forEach { it.delete() }
+ }
+ }
+}
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateInstallReceiver.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateInstallReceiver.kt
new file mode 100644
index 00000000..368ac87f
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateInstallReceiver.kt
@@ -0,0 +1,126 @@
+package ru.greemlab.neiro.update
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.util.Log
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ProcessLifecycleOwner
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/** Чем закончилась попытка установки — для экрана «О программе». */
+sealed interface UpdateInstallEvent {
+
+ /** Система приняла APK. Обычно за этим сразу следует смерть процесса. */
+ data class Installed(val versionCode: Int) : UpdateInstallEvent
+
+ /** Установщик хочет подтверждение пользователя — окно уже показано или ушло в шторку. */
+ data object AwaitingConfirmation : UpdateInstallEvent
+
+ data class Failed(val failure: UpdateFailure, val message: String?) : UpdateInstallEvent
+}
+
+/**
+ * Результат установки живёт в процессе, а не в настройках: экран «О программе»
+ * подписывается на него, а переживать перезапуск ему незачем — после успешной
+ * установки процесс всё равно новый, и там работает `updated_from_version_code`.
+ */
+object UpdateInstallStatus {
+
+ private val _events = MutableStateFlow(null)
+ val events: StateFlow = _events
+
+ fun publish(event: UpdateInstallEvent) {
+ _events.value = event
+ }
+
+ fun clear() {
+ _events.value = null
+ }
+}
+
+/**
+ * Ответ `PackageInstaller`. Три ветки, и пропустить нельзя ни одну — особенно
+ * `STATUS_PENDING_USER_ACTION`: без неё обновление молча ничего не делает у
+ * пользователя в кармане.
+ */
+class UpdateInstallReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_INSTALL_STATUS) return
+
+ val appContext = context.applicationContext
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ val versionCode = intent.getIntExtra(EXTRA_VERSION_CODE, 0)
+
+ when (status) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ @Suppress("DEPRECATION")
+ val confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ if (confirm == null) {
+ Log.w(TAG, "Система просит подтверждение, но интента в ответе нет")
+ fail(appContext, UpdateFailure.InstallFailed, message)
+ return
+ }
+ requestUserConfirmation(appContext, confirm)
+ }
+
+ PackageInstaller.STATUS_SUCCESS -> {
+ // Сюда можем и не доехать: процесс убивают почти сразу. Всё, что
+ // нужно помнить об обновлении, записано до commit.
+ Log.i(TAG, "Обновление установлено, версия $versionCode")
+ UpdateDownloader.clearDownloads(appContext)
+ UpdatePreferences.get(appContext).clearPendingApk()
+ UpdateNotifier.cancel(appContext)
+ UpdateInstallStatus.publish(UpdateInstallEvent.Installed(versionCode))
+ }
+
+ else -> {
+ Log.w(TAG, "Установка не удалась: статус $status, $message")
+ val failure = if (status == PackageInstaller.STATUS_FAILURE_ABORTED) {
+ // Пользователь нажал «Отмена» — это не поломка, и красным
+ // про это говорить не надо.
+ UpdateFailure.InstallRejected
+ } else {
+ UpdateFailure.InstallFailed
+ }
+ fail(appContext, failure, message)
+ }
+ }
+ }
+
+ private fun fail(context: Context, failure: UpdateFailure, message: String?) {
+ UpdatePreferences.get(context).clearInstallAttempt()
+ UpdateInstallStatus.publish(UpdateInstallEvent.Failed(failure, message))
+ }
+
+ /**
+ * Приложение на экране — показываем системное окно сразу. В фоне запуск
+ * активити с Android 10 запрещён, поэтому кладём тот же интент в
+ * уведомление: иначе установка тихо повиснет.
+ */
+ private fun requestUserConfirmation(context: Context, confirm: Intent) {
+ UpdateInstallStatus.publish(UpdateInstallEvent.AwaitingConfirmation)
+ confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ val foreground = ProcessLifecycleOwner.get().lifecycle.currentState
+ .isAtLeast(Lifecycle.State.STARTED)
+
+ if (foreground) {
+ val started = runCatching { context.startActivity(confirm) }.isSuccess
+ if (started) return
+ Log.w(TAG, "Окно подтверждения из фона не открылось — уходим в уведомление")
+ }
+ UpdateNotifier.notifyInstallReady(context, confirm)
+ }
+
+ companion object {
+ const val ACTION_INSTALL_STATUS = "ru.greemlab.neiro.update.INSTALL_STATUS"
+ const val EXTRA_VERSION_CODE = "version_code"
+
+ private const val TAG = "UpdateInstall"
+ }
+}
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateNotificationReceiver.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateNotificationReceiver.kt
new file mode 100644
index 00000000..4e6c4f81
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateNotificationReceiver.kt
@@ -0,0 +1,41 @@
+package ru.greemlab.neiro.update
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+
+/**
+ * Кнопка «Пропустить» в уведомлении о новой версии.
+ *
+ * Отдельный receiver, а не `startActivity`: нажатие не должно открывать
+ * приложение — человек как раз сказал, что заниматься этим сейчас не хочет.
+ * `exported="false"` в манифесте: сообщение приходит только от собственного
+ * `PendingIntent`.
+ */
+class UpdateNotificationReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_SKIP_VERSION) return
+
+ val versionCode = intent.getIntExtra(EXTRA_VERSION_CODE, 0)
+ if (versionCode <= 0) return
+
+ val appContext = context.applicationContext
+ // Пишем и «пропущено», и «уже уведомляли»: иначе следующая проверка
+ // сочла бы версию неувиденной и показала бы уведомление снова.
+ UpdatePreferences.get(appContext).apply {
+ skippedVersionCode = versionCode
+ notifiedVersionCode = maxOf(notifiedVersionCode, versionCode)
+ }
+ UpdateNotifier.cancel(appContext)
+ Log.i(TAG, "Версия $versionCode пропущена пользователем")
+ }
+
+ companion object {
+ const val ACTION_SKIP_VERSION = "ru.greemlab.neiro.update.SKIP_VERSION"
+ const val EXTRA_VERSION_CODE = "version_code"
+
+ private const val TAG = "UpdateNotifier"
+ }
+}
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt
new file mode 100644
index 00000000..929b134c
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateNotifier.kt
@@ -0,0 +1,231 @@
+package ru.greemlab.neiro.update
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import ru.greemlab.neiro.BuildConfig
+import ru.greemlab.neiro.MainActivity
+import ru.greemlab.neiro.R
+import ru.greemlab.neiro.notifications.NeiroNotificationBranding
+
+/**
+ * Системное уведомление «вышла новая версия».
+ *
+ * Отдельный канал `app_updates` с важностью `DEFAULT`: новость не срочная,
+ * звенеть и вибрировать ей незачем, а отключить её отдельно от напоминаний о
+ * занятиях пользователь должен уметь — на то каналы и придуманы.
+ *
+ * Лента уведомлений внутри приложения (`InAppNotificationStore`) намеренно не
+ * трогается: она устроена вокруг событий занятий (`SessionEventType`), и «вышла
+ * версия» туда не ложится без правки чужой модели. Признак новой версии внутри
+ * приложения — точка на пункте «О программе» (этап 8).
+ */
+object UpdateNotifier {
+
+ const val CHANNEL_ID = "app_updates"
+
+ /** Фиксированный id: об обновлении говорим одним уведомлением, не стопкой. */
+ private const val NOTIFICATION_ID = 10_006
+
+ /**
+ * Показывает уведомление, если о версии ещё не говорили и её не пропускали.
+ *
+ * @return true, если уведомление реально ушло в шторку.
+ */
+ fun notifyIfNeeded(context: Context, info: UpdateInfo): Boolean {
+ val appContext = context.applicationContext
+ val preferences = UpdatePreferences.get(appContext)
+ val versionCode = info.version.versionCode
+
+ val allowed = shouldNotify(
+ versionCode = versionCode,
+ notifiedVersionCode = preferences.notifiedVersionCode,
+ skippedVersionCode = preferences.skippedVersionCode,
+ )
+ if (!allowed) return false
+
+ ensureChannel(appContext)
+ if (!NotificationManagerCompat.from(appContext).areNotificationsEnabled()) {
+ // Разрешения нет — отметку не ставим: выдаст позже, узнает о версии.
+ return false
+ }
+
+ val title = appContext.getString(R.string.notification_update_title, info.version.versionName)
+ val body = firstNotesLine(info) ?: appContext.getString(R.string.notification_update_body)
+
+ val notification = NeiroNotificationBranding.apply(
+ NotificationCompat.Builder(appContext, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(openAppIntent(appContext))
+ .addAction(
+ 0,
+ appContext.getString(R.string.notification_update_skip),
+ skipIntent(appContext, versionCode),
+ ),
+ appContext,
+ ).build()
+
+ val shown = try {
+ NotificationManagerCompat.from(appContext).notify(NOTIFICATION_ID, notification)
+ true
+ } catch (_: SecurityException) {
+ // POST_NOTIFICATIONS не выдан или ошибка AppOps (uid -1).
+ false
+ }
+
+ // Отметка ставится только после успешного показа: иначе одно неудачное
+ // уведомление навсегда похоронило бы новость об этой версии.
+ if (shown) preferences.notifiedVersionCode = versionCode
+ return shown
+ }
+
+ /**
+ * Известна ли версия новее установленной — для точки на пункте «О программе».
+ * Берётся из кэша прошлой проверки, в сеть не ходит.
+ *
+ * @return версия для подписи или null, если показывать нечего.
+ */
+ fun knownNewerVersion(context: Context): ReleaseVersion? {
+ val appContext = context.applicationContext
+ if (!UpdateChannelGate.isAllowed(appContext)) return null
+
+ val preferences = UpdatePreferences.get(appContext)
+ val known = preferences.lastKnownVersionCode
+ if (known <= BuildConfig.VERSION_CODE) return null
+ if (known <= preferences.skippedVersionCode) return null
+ return ReleaseVersion.fromVersionCode(known)
+ }
+
+ /**
+ * Политика «когда вообще стоит говорить». Чистая функция без Android —
+ * проверяется тестами (`UpdateNotificationPolicyTest`), потому что именно
+ * здесь живут все три случая, которые легко перепутать: та же версия
+ * дважды, пропущенная версия, следующая после пропущенной.
+ */
+ fun shouldNotify(versionCode: Int, notifiedVersionCode: Int, skippedVersionCode: Int): Boolean {
+ // Пропущенная версия и всё, что её старее, молчит до следующего выпуска.
+ if (versionCode <= skippedVersionCode) return false
+ return versionCode > notifiedVersionCode
+ }
+
+ /**
+ * «Нажмите, чтобы установить» — когда установщик просит подтверждение, а
+ * приложение в фоне: с Android 10 запуск активити оттуда запрещён, и без
+ * этого уведомления обновление тихо повисло бы.
+ */
+ fun notifyInstallReady(context: Context, confirmIntent: Intent): Boolean {
+ val appContext = context.applicationContext
+ ensureChannel(appContext)
+ if (!NotificationManagerCompat.from(appContext).areNotificationsEnabled()) return false
+
+ val pending = PendingIntent.getActivity(
+ appContext,
+ REQUEST_INSTALL,
+ confirmIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val notification = NeiroNotificationBranding.apply(
+ NotificationCompat.Builder(appContext, CHANNEL_ID)
+ .setContentTitle(appContext.getString(R.string.notification_update_install_title))
+ .setContentText(appContext.getString(R.string.notification_update_install_body))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(pending),
+ appContext,
+ ).build()
+
+ return try {
+ NotificationManagerCompat.from(appContext).notify(NOTIFICATION_ID_INSTALL, notification)
+ true
+ } catch (_: SecurityException) {
+ false
+ }
+ }
+
+ /** Убрать уведомления пакета — после установки или по «Пропустить». */
+ fun cancel(context: Context) {
+ NotificationManagerCompat.from(context.applicationContext).apply {
+ cancel(NOTIFICATION_ID)
+ cancel(NOTIFICATION_ID_INSTALL)
+ }
+ }
+
+ fun ensureChannel(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val appContext = context.applicationContext
+ val manager = appContext.getSystemService(NotificationManager::class.java) ?: return
+ try {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ appContext.getString(R.string.notification_channel_updates),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = appContext.getString(R.string.notification_channel_updates_desc)
+ enableVibration(false)
+ lightColor = NeiroNotificationBranding.channelLightColor(appContext)
+ }
+ manager.createNotificationChannel(channel)
+ } catch (_: SecurityException) {
+ // Ошибки AppOps/UID при создании канала на некоторых устройствах.
+ }
+ }
+
+ /**
+ * Первая содержательная строка описания релиза. Заголовки Markdown и
+ * пустые строки пропускаем: «## Добавлено» в шторке ничего не объясняет.
+ */
+ private fun firstNotesLine(info: UpdateInfo): String? = info.notes
+ .lineSequence()
+ .map { it.trim().removePrefix("-").removePrefix("*").trim() }
+ .firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
+ ?.takeIf { it.isNotBlank() }
+
+ private fun openAppIntent(context: Context): PendingIntent {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra(MainActivity.EXTRA_OPEN_ABOUT, true)
+ }
+ return PendingIntent.getActivity(
+ context,
+ REQUEST_OPEN,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun skipIntent(context: Context, versionCode: Int): PendingIntent {
+ val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
+ action = UpdateNotificationReceiver.ACTION_SKIP_VERSION
+ putExtra(UpdateNotificationReceiver.EXTRA_VERSION_CODE, versionCode)
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ // Свой requestCode на версию: иначе FLAG_UPDATE_CURRENT переписал бы
+ // extras уже висящего уведомления о прошлом выпуске.
+ REQUEST_SKIP_BASE + (versionCode % 1000),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ /** Подтверждение установки — своё уведомление: оно живёт отдельно от новости о версии. */
+ private const val NOTIFICATION_ID_INSTALL = 10_007
+
+ private const val REQUEST_OPEN = 4_100
+ private const val REQUEST_SKIP_BASE = 4_200
+ private const val REQUEST_INSTALL = 4_400
+}
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt
index c2c2efd3..5782b699 100644
--- a/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdatePreferences.kt
@@ -1,6 +1,7 @@
package ru.greemlab.neiro.update
import android.content.Context
+import ru.greemlab.neiro.BuildConfig
/**
* Настройки и метаданные самообновления — по образцу
@@ -52,6 +53,79 @@ class UpdatePreferences(context: Context) : UpdateCheckStore {
get() = prefs.getLong(KEY_RATE_LIMITED_UNTIL, 0L)
set(value) = prefs.edit().putLong(KEY_RATE_LIMITED_UNTIL, value).apply()
+ /**
+ * О какой версии уже говорили в шторке. Иначе телефон сообщал бы об одном и
+ * том же выпуске каждые сутки, пока его не поставят.
+ */
+ var notifiedVersionCode: Int
+ get() = prefs.getInt(KEY_NOTIFIED_VERSION_CODE, 0)
+ set(value) = prefs.edit().putInt(KEY_NOTIFIED_VERSION_CODE, value).apply()
+
+ /**
+ * «Пропустить эту версию» — молчим, пока не выйдет следующая. Хранится
+ * `versionCode`, а не флаг: следующий выпуск будет больше числом и снова
+ * пробьётся к пользователю сам.
+ */
+ var skippedVersionCode: Int
+ get() = prefs.getInt(KEY_SKIPPED_VERSION_CODE, 0)
+ set(value) = prefs.edit().putInt(KEY_SKIPPED_VERSION_CODE, value).apply()
+
+ /** Скачанный и проверенный, но ещё не установленный APK. */
+ var pendingApkPath: String?
+ get() = prefs.getString(KEY_PENDING_APK_PATH, null)
+ set(value) = prefs.edit().putString(KEY_PENDING_APK_PATH, value).apply()
+
+ var pendingVersionCode: Int
+ get() = prefs.getInt(KEY_PENDING_VERSION_CODE, 0)
+ set(value) = prefs.edit().putInt(KEY_PENDING_VERSION_CODE, value).apply()
+
+ /**
+ * С какой версии уходили, когда отдавали APK установщику. Читается после
+ * перезапуска: больше нуля и меньше текущей — обновление состоялось.
+ */
+ val updatedFromVersionCode: Int
+ get() = prefs.getInt(KEY_UPDATED_FROM_VERSION_CODE, 0)
+
+ /**
+ * Отметка «сейчас будет установка». Пишется **до** `commit` сессии и через
+ * `commit()`, а не `apply()`: после успешной установки процесс убивают, и
+ * асинхронная запись не успеет (RISKS.md § Установка убивает процесс).
+ */
+ fun recordInstallAttempt(targetVersionCode: Int) {
+ prefs.edit()
+ .putInt(KEY_UPDATED_FROM_VERSION_CODE, BuildConfig.VERSION_CODE)
+ .putInt(KEY_PENDING_VERSION_CODE, targetVersionCode)
+ .commit()
+ }
+
+ /**
+ * Прочитать отметку об установке и стереть её: «Обновлено до 0.1.2»
+ * показывается один раз, а не при каждом открытии экрана.
+ *
+ * @return версия, с которой уходили, или 0 — обновления не было.
+ */
+ fun consumeUpdatedFrom(): Int {
+ val from = updatedFromVersionCode
+ if (from > 0) clearInstallAttempt()
+ return from
+ }
+
+ /** Установка не началась или провалилась — отметка не должна пережить попытку. */
+ fun clearInstallAttempt() {
+ prefs.edit()
+ .remove(KEY_UPDATED_FROM_VERSION_CODE)
+ .remove(KEY_PENDING_VERSION_CODE)
+ .apply()
+ }
+
+ /** Забыть скачанный файл: он установлен, удалён или не прошёл проверку. */
+ fun clearPendingApk() {
+ prefs.edit()
+ .remove(KEY_PENDING_APK_PATH)
+ .remove(KEY_PENDING_VERSION_CODE)
+ .apply()
+ }
+
/** Забыть, что и когда проверяли: следующая проверка пойдёт в сеть сразу. */
fun clearCheckState() {
prefs.edit()
@@ -67,6 +141,11 @@ class UpdatePreferences(context: Context) : UpdateCheckStore {
private const val KEY_LAST_CHECK_EPOCH = "last_check_epoch"
private const val KEY_LAST_KNOWN_VERSION_CODE = "last_known_version_code"
private const val KEY_RATE_LIMITED_UNTIL = "rate_limited_until"
+ private const val KEY_NOTIFIED_VERSION_CODE = "notified_version_code"
+ private const val KEY_SKIPPED_VERSION_CODE = "skipped_version_code"
+ private const val KEY_PENDING_APK_PATH = "pending_apk_path"
+ private const val KEY_PENDING_VERSION_CODE = "pending_version_code"
+ private const val KEY_UPDATED_FROM_VERSION_CODE = "updated_from_version_code"
@Volatile
private var instance: UpdatePreferences? = null
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateState.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateState.kt
new file mode 100644
index 00000000..b2378a9a
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateState.kt
@@ -0,0 +1,58 @@
+package ru.greemlab.neiro.update
+
+import java.io.File
+
+/**
+ * Состояние экрана «О программе». Одна цепочка на весь путь обновления:
+ * проверка → найдено → скачивание → сверка → готово → установка → результат.
+ *
+ * Отдельно от [UpdateStatus] намеренно: тот — ответ на единственный вопрос
+ * «есть ли новее», и живёт в фоне, без экрана. Здесь же есть промежуточные
+ * шаги, которые видит только пользователь.
+ */
+sealed interface UpdateState {
+
+ data object Idle : UpdateState
+
+ data object Checking : UpdateState
+
+ data class UpToDate(val checkedAt: Long) : UpdateState
+
+ data class Available(val info: UpdateInfo) : UpdateState
+
+ data class Downloading(val info: UpdateInfo, val percent: Int) : UpdateState
+
+ data class Verifying(val info: UpdateInfo) : UpdateState
+
+ data class ReadyToInstall(val info: UpdateInfo, val apk: File) : UpdateState
+
+ data class Installing(val info: UpdateInfo) : UpdateState
+
+ /** Система требует подтверждения — ждём, пока пользователь ответит. */
+ data class AwaitingConfirmation(val info: UpdateInfo) : UpdateState
+
+ data class Failed(val reason: UpdateFailure, val info: UpdateInfo?) : UpdateState
+
+ /** Сборка из магазина или debug — обновлять себя нельзя. */
+ data class Blocked(val why: UpdateBlockReason) : UpdateState
+}
+
+/** Информация о версии, которую состояние несёт с собой, если она уже известна. */
+val UpdateState.info: UpdateInfo?
+ get() = when (this) {
+ is UpdateState.Available -> info
+ is UpdateState.Downloading -> info
+ is UpdateState.Verifying -> info
+ is UpdateState.ReadyToInstall -> info
+ is UpdateState.Installing -> info
+ is UpdateState.AwaitingConfirmation -> info
+ is UpdateState.Failed -> info
+ else -> null
+ }
+
+/** Идёт работа — кнопку «Проверить» в это время показывать нечем. */
+val UpdateState.isBusy: Boolean
+ get() = this is UpdateState.Checking ||
+ this is UpdateState.Downloading ||
+ this is UpdateState.Verifying ||
+ this is UpdateState.Installing
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateVerifier.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateVerifier.kt
new file mode 100644
index 00000000..88ccfc69
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateVerifier.kt
@@ -0,0 +1,155 @@
+package ru.greemlab.neiro.update
+
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.Signature
+import android.os.Build
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Разбор `SHA256SUMS.txt`. Формат — вывод `sha256sum`: сумма, пробелы, имя
+ * файла; в бинарном режиме перед именем стоит звёздочка.
+ *
+ * Чистые функции без Android — проверяются тестами (`Sha256SumsParserTest`).
+ */
+object Sha256Sums {
+
+ private val LINE_REGEX = Regex("""^([0-9a-fA-F]{64})\s+\*?(.+)$""")
+
+ /** null — строки про этот файл в списке нет, сверять нечем. */
+ fun findChecksum(content: String?, fileName: String): String? {
+ if (content.isNullOrBlank() || fileName.isBlank()) return null
+ return content.lineSequence()
+ .mapNotNull { LINE_REGEX.matchEntire(it.trim()) }
+ .firstOrNull { it.groupValues[2].trim().equals(fileName, ignoreCase = true) }
+ ?.groupValues
+ ?.get(1)
+ ?.lowercase()
+ }
+}
+
+/**
+ * Две обязательные проверки перед установкой — обе до неё, а не после.
+ *
+ * 1. **Сумма из релиза** — защищает от битой закачки и подменённого ассета.
+ * 2. **Подпись APK против собственной** — защищает от главного тихого сценария:
+ * файл целый, но подписан не нашим ключом. Система откажется его ставить и
+ * покажет «Приложение не установлено» без объяснений; мы проверяем сами и
+ * говорим человеческим языком.
+ */
+object UpdateVerifier {
+
+ /** @return причина отказа; null — файл можно ставить. */
+ suspend fun verify(
+ context: Context,
+ apk: File,
+ checksumsContent: String?,
+ ): UpdateFailure? = withContext(Dispatchers.IO) {
+ if (!apk.isFile || apk.length() == 0L) return@withContext UpdateFailure.DownloadFailed
+
+ val expected = Sha256Sums.findChecksum(checksumsContent, apk.name)
+ if (expected == null) {
+ Log.w(TAG, "В SHA256SUMS.txt нет строки про ${apk.name}")
+ apk.delete()
+ return@withContext UpdateFailure.MalformedRelease
+ }
+
+ val actual = sha256(apk)
+ if (!actual.equals(expected, ignoreCase = true)) {
+ Log.w(TAG, "Сумма не сошлась: ждали $expected, получили $actual")
+ apk.delete()
+ return@withContext UpdateFailure.ChecksumMismatch
+ }
+
+ if (!signatureMatches(context.applicationContext, apk)) {
+ Log.w(TAG, "Подпись APK не совпадает с нашей — файл удалён")
+ apk.delete()
+ return@withContext UpdateFailure.SignatureMismatch
+ }
+
+ null
+ }
+
+ fun sha256(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ digest.update(buffer, 0, read)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ /**
+ * Сертификаты сравниваем по SHA-256 их байтов, а не по объектам `Signature`:
+ * так же, как это делает система, и без зависимости от порядка в массиве.
+ */
+ private fun signatureMatches(context: Context, apk: File): Boolean {
+ val installed = certificateHashes(installedPackageInfo(context))
+ val candidate = certificateHashes(archivePackageInfo(context, apk))
+ if (installed.isEmpty() || candidate.isEmpty()) {
+ // Прочитать подпись не вышло — считаем это несовпадением: пропустить
+ // непроверенный APK хуже, чем отказаться от подозрительного.
+ Log.w(TAG, "Подписи прочитать не удалось (установлено: ${installed.size}, файл: ${candidate.size})")
+ return false
+ }
+ // Пересечение, а не строгое равенство: после ротации ключа у
+ // установленной сборки в истории лежат оба сертификата, а у нового APK —
+ // только новый. Система в такой паре обновление принимает, и наша
+ // проверка не должна быть строже установщика.
+ return installed.any { it in candidate }
+ }
+
+ private fun installedPackageInfo(context: Context): PackageInfo? = runCatching {
+ val pm = context.packageManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
+ }
+ }.getOrNull()
+
+ private fun archivePackageInfo(context: Context, apk: File): PackageInfo? = runCatching {
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ @Suppress("DEPRECATION")
+ PackageManager.GET_SIGNATURES
+ }
+ context.packageManager.getPackageArchiveInfo(apk.absolutePath, flags)
+ }.getOrNull()
+
+ private fun certificateHashes(info: PackageInfo?): Set {
+ if (info == null) return emptySet()
+ val signatures: Array? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val signing = info.signingInfo
+ when {
+ signing == null -> null
+ signing.hasMultipleSigners() -> signing.apkContentsSigners
+ else -> signing.signingCertificateHistory
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ info.signatures
+ }
+
+ return signatures.orEmpty().mapNotNull { signature ->
+ runCatching {
+ MessageDigest.getInstance("SHA-256")
+ .digest(signature.toByteArray())
+ .joinToString("") { "%02x".format(it) }
+ }.getOrNull()
+ }.toSet()
+ }
+
+ private const val TAG = "UpdateVerifier"
+}
diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt
new file mode 100644
index 00000000..0d72c41c
--- /dev/null
+++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateViewModel.kt
@@ -0,0 +1,234 @@
+package ru.greemlab.neiro.update
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import ru.greemlab.neiro.BuildConfig
+import java.io.File
+
+/**
+ * Состояние экрана «О программе».
+ *
+ * Скачивание и установка запускаются только отсюда, то есть только по нажатию
+ * пользователя: фоновая проверка (`UpdateCheckWorker`) умеет ровно узнать и
+ * сказать, дальше — его решение.
+ */
+class UpdateViewModel(application: Application) : AndroidViewModel(application) {
+
+ /**
+ * Своё поле вместо `getApplication()`: тот объявлен как ` T`,
+ * и в местах, где ждут `Context`, вывод типа спотыкается.
+ */
+ private val app: Application = application
+
+ private val preferences = UpdatePreferences.get(application)
+ private val downloader = UpdateDownloader()
+
+ private val _state = MutableStateFlow(UpdateState.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _autoCheckEnabled = MutableStateFlow(preferences.isAutoCheckEnabled)
+ val autoCheckEnabled: StateFlow = _autoCheckEnabled.asStateFlow()
+
+ private val _lastCheckAt = MutableStateFlow(preferences.lastCheckEpochMillis)
+ val lastCheckAt: StateFlow = _lastCheckAt.asStateFlow()
+
+ val versionName: String = BuildConfig.VERSION_NAME
+ val versionCode: Int = BuildConfig.VERSION_CODE
+
+ /** «Обновлено до 0.1.2» — показывается один раз после успешной установки. */
+ private val _justUpdatedTo = MutableStateFlow(null)
+ val justUpdatedTo: StateFlow = _justUpdatedTo.asStateFlow()
+
+ init {
+ UpdateChannelGate.blockReason(application)?.let { _state.value = UpdateState.Blocked(it) }
+ restoreAfterInstall()
+ observeInstallStatus()
+ }
+
+ /**
+ * Что осталось от прошлого запуска: отметка об установке и, может быть,
+ * скачанный, но не поставленный APK. Второе важнее, чем кажется: без него
+ * человек, закрывший системный диалог, качал бы те же 15 МБ заново.
+ */
+ private fun restoreAfterInstall() {
+ val updatedFrom = preferences.consumeUpdatedFrom()
+ if (updatedFrom in 1 until BuildConfig.VERSION_CODE) {
+ _justUpdatedTo.value = BuildConfig.VERSION_NAME
+ UpdateDownloader.clearDownloads(app)
+ preferences.clearPendingApk()
+ UpdateNotifier.cancel(app)
+ return
+ }
+
+ val pendingPath = preferences.pendingApkPath
+ val pendingVersion = preferences.pendingVersionCode
+ if (pendingPath.isNullOrBlank() || pendingVersion <= BuildConfig.VERSION_CODE) {
+ // Файл от старой или уже установленной версии — только место занимает.
+ if (pendingPath != null) {
+ UpdateDownloader.clearDownloads(app)
+ preferences.clearPendingApk()
+ }
+ return
+ }
+
+ val apk = File(pendingPath)
+ val version = ReleaseVersion.fromVersionCode(pendingVersion)
+ if (!apk.isFile || apk.length() == 0L || version == null) {
+ preferences.clearPendingApk()
+ return
+ }
+
+ // Ссылок и заметок после перезапуска нет — для установки они и не нужны.
+ val info = UpdateInfo(
+ version = version,
+ title = "Neiro ${version.versionName}",
+ notes = "",
+ releaseUrl = "",
+ apkName = apk.name,
+ apkUrl = "",
+ apkSizeBytes = apk.length(),
+ checksumsUrl = "",
+ )
+ _state.value = UpdateState.ReadyToInstall(info, apk)
+ }
+
+ /** Отметку прочитали и показали — второй раз не надо. */
+ fun dismissJustUpdated() {
+ _justUpdatedTo.value = null
+ }
+
+ /** Кнопка «Проверить обновления». Ручная проверка суточный троттлинг игнорирует. */
+ fun check(force: Boolean = true) {
+ if (_state.value.isBusy) return
+ val blocked = UpdateChannelGate.blockReason(app)
+ if (blocked != null) {
+ _state.value = UpdateState.Blocked(blocked)
+ return
+ }
+
+ _state.value = UpdateState.Checking
+ viewModelScope.launch {
+ val status = UpdateCheckCoordinator.checkNow(app, force)
+ _lastCheckAt.value = preferences.lastCheckEpochMillis
+ _state.value = when (status) {
+ is UpdateStatus.Available -> UpdateState.Available(status.info)
+ is UpdateStatus.UpToDate -> UpdateState.UpToDate(status.checkedAt)
+ is UpdateStatus.Throttled -> UpdateState.UpToDate(status.checkedAt)
+ is UpdateStatus.Blocked -> UpdateState.Blocked(status.why)
+ is UpdateStatus.Failed -> UpdateState.Failed(status.failure, null)
+ }
+ }
+ }
+
+ /**
+ * Скачать, сверить и отдать установщику — три шага одной кнопкой, потому что
+ * пользователю это один поступок. Каждый шаг умеет остановить цепочку.
+ */
+ fun downloadAndInstall(info: UpdateInfo) {
+ if (_state.value.isBusy) return
+
+ viewModelScope.launch {
+ _state.value = UpdateState.Downloading(info, 0)
+
+ val outcome = downloader.download(app, info) { percent ->
+ val current = _state.value
+ if (current is UpdateState.Downloading) {
+ _state.value = current.copy(percent = percent)
+ }
+ }
+ val apk = when (outcome) {
+ is DownloadOutcome.Success -> outcome.apk
+ is DownloadOutcome.Failed -> {
+ _state.value = UpdateState.Failed(outcome.failure, info)
+ return@launch
+ }
+ }
+
+ _state.value = UpdateState.Verifying(info)
+ val checksums = downloader.downloadChecksums(info.checksumsUrl)
+ val failure = UpdateVerifier.verify(app, apk, checksums)
+ if (failure != null) {
+ // Файл уже удалён внутри проверки — не оставляем 15 МБ мусора.
+ _state.value = UpdateState.Failed(failure, info)
+ return@launch
+ }
+
+ preferences.pendingApkPath = apk.absolutePath
+ preferences.pendingVersionCode = info.version.versionCode
+ _state.value = UpdateState.ReadyToInstall(info, apk)
+ install(info, apk)
+ }
+ }
+
+ /** Отдать проверенный файл системе. Отдельно от загрузки: после отказа можно повторить. */
+ fun install(info: UpdateInfo, apk: File) {
+ viewModelScope.launch {
+ if (!ApkInstaller.canInstall(app)) {
+ // Разрешение выдаётся в системных настройках, обойти нельзя.
+ _state.value = UpdateState.Failed(UpdateFailure.InstallRejected, info)
+ return@launch
+ }
+
+ _state.value = UpdateState.Installing(info)
+ val failure = ApkInstaller.install(app, apk, info.version.versionCode)
+ if (failure != null) {
+ _state.value = UpdateState.Failed(failure, info)
+ }
+ // Иначе ждём ответа установщика — он придёт в UpdateInstallStatus.
+ }
+ }
+
+ /** «Пропустить» — молчим об этой версии, пока не выйдет следующая. */
+ fun skip(info: UpdateInfo) {
+ preferences.skippedVersionCode = info.version.versionCode
+ preferences.notifiedVersionCode = maxOf(
+ preferences.notifiedVersionCode,
+ info.version.versionCode,
+ )
+ UpdateNotifier.cancel(app)
+ UpdateDownloader.clearDownloads(app)
+ preferences.clearPendingApk()
+ _state.value = UpdateState.UpToDate(preferences.lastCheckEpochMillis)
+ }
+
+ fun setAutoCheckEnabled(enabled: Boolean) {
+ _autoCheckEnabled.value = enabled
+ UpdateCheckCoordinator.onAutoCheckToggled(app, enabled)
+ }
+
+ fun needsInstallPermission(): Boolean = !ApkInstaller.canInstall(app)
+
+ fun installPermissionIntent() = ApkInstaller.unknownSourcesSettingsIntent(app)
+
+ /** Сбросить ошибку, чтобы экран вернулся к обычному виду. */
+ fun dismissFailure() {
+ if (_state.value is UpdateState.Failed) {
+ _state.value = UpdateState.UpToDate(preferences.lastCheckEpochMillis)
+ }
+ UpdateInstallStatus.clear()
+ }
+
+ private fun observeInstallStatus() {
+ viewModelScope.launch {
+ UpdateInstallStatus.events.collect { event ->
+ val info = _state.value.info
+ when (event) {
+ null -> Unit
+ is UpdateInstallEvent.Installed -> _state.value =
+ UpdateState.UpToDate(preferences.lastCheckEpochMillis)
+
+ is UpdateInstallEvent.AwaitingConfirmation ->
+ if (info != null) _state.value = UpdateState.AwaitingConfirmation(info)
+
+ is UpdateInstallEvent.Failed ->
+ _state.value = UpdateState.Failed(event.failure, info)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 96e48a9c..ae2b1ae1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -168,6 +168,59 @@
Показывать аватар
Если выключено, будет отображаться логотип приложения
+ Обновления приложения
+ Сообщения о новых версиях Neiro
+ Neiro %1$s — доступно обновление
+ Нажмите, чтобы посмотреть, что изменилось
+ Пропустить
+ Обновление готово
+ Нажмите, чтобы установить новую версию
+
+ О программе
+ Версия
+ Обновления
+ Сборка %1$d · установлено из GitHub
+ Проверить обновления
+ Спрашиваю GitHub…
+ Последняя проверка: %1$s
+ Ещё ни разу не проверяли
+ Проверять автоматически
+ Раз в сутки, когда есть сеть
+ Установлена последняя версия
+ Доступна %1$s
+ Что изменилось:
+ Обновить
+ Пропустить
+ Открыть на GitHub
+ Скачиваю: %1$d %%
+ Проверяю файл: сумма и подпись
+ Устанавливаю…
+ Подтвердите установку в системном окне
+ Версия %1$s скачана и проверена — осталось установить
+ Установить
+ Обновлено до %1$s
+ Android просит один раз разрешить установку приложений из Neiro — без этого система не примет файл.
+ Открыть настройки
+ Понятно
+ Отладочная сборка себя не обновляет
+ Обновления приходят из RuStore
+ Обновления приходят из Google Play
+
+ Не получилось связаться с GitHub. Проверьте сеть и попробуйте позже.
+ GitHub временно не отвечает на запросы. Попробуйте через час.
+ Новых версий пока нет.
+ Релиз собран неправильно: нужного файла в нём нет. Обновление отменено.
+ Файл не докачался. Попробуйте ещё раз.
+ Контрольная сумма не сошлась — файл повреждён и удалён.
+ Файл подписан чужим ключом и удалён. Ставить его нельзя.
+ Не хватает места на телефоне. Освободите около 20 МБ.
+ Установка не разрешена. Дайте Neiro право устанавливать приложения.
+ Система не смогла установить обновление.
+
+ О программе
+ Версия %1$s
+ Доступна версия %1$s
+
DEBUG
PRE-RELEASE
RELEASE
diff --git a/app/src/test/java/ru/greemlab/neiro/update/Sha256SumsParserTest.kt b/app/src/test/java/ru/greemlab/neiro/update/Sha256SumsParserTest.kt
new file mode 100644
index 00000000..d3d38825
--- /dev/null
+++ b/app/src/test/java/ru/greemlab/neiro/update/Sha256SumsParserTest.kt
@@ -0,0 +1,70 @@
+package ru.greemlab.neiro.update
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * Разбор `SHA256SUMS.txt`. Файл делает `sha256sum` в релизном workflow, но
+ * приложение обязано пережить и список из нескольких файлов, и бинарный режим
+ * со звёздочкой, и лишние пробелы: не сошлось — обновление отменяется, а не
+ * ставится «как-нибудь».
+ */
+class Sha256SumsParserTest {
+
+ private val apkHash = "9f2c8d1b4a7e6f0c3d5b8a1e4f7c0d2b5a8e1f4c7d0b3a6e9f2c5d8b1a4e7f0c"
+ private val mappingHash = "1a2b3c4d5e6f70819293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
+
+ @Test
+ fun `нужная строка находится среди нескольких`() {
+ val content = """
+ $mappingHash mapping-0.2.0.txt
+ $apkHash neiro-0.2.0.apk
+ """.trimIndent()
+
+ assertEquals(apkHash, Sha256Sums.findChecksum(content, "neiro-0.2.0.apk"))
+ }
+
+ @Test
+ fun `бинарный режим со звёздочкой разбирается`() {
+ val content = "$apkHash *neiro-0.2.0.apk"
+
+ assertEquals(apkHash, Sha256Sums.findChecksum(content, "neiro-0.2.0.apk"))
+ }
+
+ @Test
+ fun `лишние пробелы и пустые строки не мешают`() {
+ val content = "\n $apkHash neiro-0.2.0.apk \n\n"
+
+ assertEquals(apkHash, Sha256Sums.findChecksum(content, "neiro-0.2.0.apk"))
+ }
+
+ @Test
+ fun `сумма приводится к нижнему регистру`() {
+ val content = "${apkHash.uppercase()} neiro-0.2.0.apk"
+
+ assertEquals(apkHash, Sha256Sums.findChecksum(content, "neiro-0.2.0.apk"))
+ }
+
+ @Test
+ fun `нужного имени в списке нет`() {
+ val content = "$mappingHash mapping-0.2.0.txt"
+
+ assertNull(Sha256Sums.findChecksum(content, "neiro-0.2.0.apk"))
+ }
+
+ @Test
+ fun `мусор вместо суммы не проходит`() {
+ // Короткая строка и текст ошибки вместо файла — GitHub иногда отдаёт
+ // HTML вместо ассета, и такой «ответ» не должен стать суммой.
+ assertNull(Sha256Sums.findChecksum("deadbeef neiro-0.2.0.apk", "neiro-0.2.0.apk"))
+ assertNull(Sha256Sums.findChecksum("404", "neiro-0.2.0.apk"))
+ }
+
+ @Test
+ fun `пустой и отсутствующий файл не роняют разбор`() {
+ assertNull(Sha256Sums.findChecksum(null, "neiro-0.2.0.apk"))
+ assertNull(Sha256Sums.findChecksum("", "neiro-0.2.0.apk"))
+ assertNull(Sha256Sums.findChecksum("$apkHash neiro-0.2.0.apk", ""))
+ }
+}
diff --git a/app/src/test/java/ru/greemlab/neiro/update/UpdateNotificationPolicyTest.kt b/app/src/test/java/ru/greemlab/neiro/update/UpdateNotificationPolicyTest.kt
new file mode 100644
index 00000000..40414fc0
--- /dev/null
+++ b/app/src/test/java/ru/greemlab/neiro/update/UpdateNotificationPolicyTest.kt
@@ -0,0 +1,41 @@
+package ru.greemlab.neiro.update
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Когда о версии говорить, а когда молчать. Три случая, которые легко
+ * перепутать: та же версия дважды, пропущенная версия и следующая после
+ * пропущенной — телефон, звенящий каждые сутки об одном и том же выпуске,
+ * выключают целиком.
+ */
+class UpdateNotificationPolicyTest {
+
+ @Test
+ fun `о новой версии говорим`() {
+ assertTrue(UpdateNotifier.shouldNotify(versionCode = 200, notifiedVersionCode = 0, skippedVersionCode = 0))
+ }
+
+ @Test
+ fun `о той же версии второй раз молчим`() {
+ assertFalse(UpdateNotifier.shouldNotify(versionCode = 200, notifiedVersionCode = 200, skippedVersionCode = 0))
+ }
+
+ @Test
+ fun `пропущенная версия молчит`() {
+ assertFalse(UpdateNotifier.shouldNotify(versionCode = 200, notifiedVersionCode = 200, skippedVersionCode = 200))
+ }
+
+ @Test
+ fun `следующая после пропущенной пробивается сама`() {
+ assertTrue(UpdateNotifier.shouldNotify(versionCode = 201, notifiedVersionCode = 200, skippedVersionCode = 200))
+ }
+
+ @Test
+ fun `версия старее пропущенной не всплывает`() {
+ // Такое бывает после отката релиза: в GitHub снова оказалась старая
+ // версия, но пользователь про неё уже сказал «не хочу».
+ assertFalse(UpdateNotifier.shouldNotify(versionCode = 199, notifiedVersionCode = 0, skippedVersionCode = 200))
+ }
+}
diff --git a/docs/updater/README.md b/docs/updater/README.md
index 2c211d89..dd9f9a96 100644
--- a/docs/updater/README.md
+++ b/docs/updater/README.md
@@ -3,12 +3,12 @@
Пакет документов о том, как Neiro выпускается версиями и как обновляет себя
сама, без магазина и без ручной переустановки по кабелю.
-Статус: **в работе.** Готовы этапы 0–2 и весь блок оформления: версия из
-`version.properties`, релизный workflow по тегу, первый релиз `v0.1.0` выпущен
-11.08.2026. В пакете `update` написаны ядро проверки (этап 3) и суточная
-проверка (этап 4) — код ни разу не собирался и ждёт тестов. Уведомление,
-загрузка, установка и экран «О программе» (этапы 5–8) ещё впереди — ниже
-описано, что и почему будет. Работа
+Статус: **код написан целиком, не собран ни разу.** Готовы этапы 0–2 и весь
+блок оформления, первый релиз `v0.1.0` выпущен 11.08.2026. Самообновление
+внутри приложения (этапы 3–9) и заметки релиза из `CHANGELOG` (О8) написаны
+11.08.2026: проверка, уведомление, загрузка со сверкой суммы и подписи,
+установка через `PackageInstaller`, экран «О программе». Осталось собрать,
+прогнать тесты и проверить на устройстве — Gradle агент не запускает. Работа
идёт по [TASKS.md](TASKS.md), этап за этапом, коммит на этап; там же сводная
таблица «[где мы сейчас](TASKS.md#где-мы-сейчас)» — единственное место, где
стоят статусы.
diff --git a/docs/updater/TASKS.md b/docs/updater/TASKS.md
index f7cbff18..65f9cb66 100644
--- a/docs/updater/TASKS.md
+++ b/docs/updater/TASKS.md
@@ -64,28 +64,29 @@
| [Тег](#первый-тег) | Слияние в `main`, `v0.1.0` | ✅ готово 11.08.2026 — релиз «Neiro 0.1.0» с тремя ассетами |
| [3](#этап-3--ядро-проверки) | Ядро проверки: пакет `update` | 🟡 код написан, тесты не запускались |
| [4](#этап-4--суточная-проверка) | Суточная проверка | 🟡 код написан, тесты не запускались |
-| [5](#этап-5--уведомление-о-версии) | Уведомление о версии | ⬜ |
-| [6](#этап-6--загрузка-и-проверка-файла) | Загрузка и проверка файла | ⬜ |
-| [7](#этап-7--установка) | Установка через `PackageInstaller` | ⬜ |
-| [8](#этап-8--экран-о-программе) | Экран «О программе» — **та самая кнопка** | ⬜ |
-| [9](#этап-9--после-установки) | Отметка после установки | ⬜ |
-| [О8](#этап-о8--заметки-релиза-из-changelog) | Заметки релиза из `CHANGELOG` | ⬜ |
+| [5](#этап-5--уведомление-о-версии) | Уведомление о версии | 🟡 код написан, тесты не запускались |
+| [6](#этап-6--загрузка-и-проверка-файла) | Загрузка и проверка файла | 🟡 код написан, тесты не запускались |
+| [7](#этап-7--установка) | Установка через `PackageInstaller` | 🟡 код написан, на устройстве не проверялся |
+| [8](#этап-8--экран-о-программе) | Экран «О программе» — **та самая кнопка** | 🟡 код написан, на устройстве не смотрели |
+| [9](#этап-9--после-установки) | Отметка после установки | 🟡 код написан, на устройстве не проверялся |
+| [О8](#этап-о8--заметки-релиза-из-changelog) | Заметки релиза из `CHANGELOG` | 🟡 извлечение проверено локально, на релизе — нет |
| [10](#этап-10--скриншоты) | Скриншоты | ⬜ |
| [11](#этап-11--проверка-на-устройстве) | Проверка на устройстве | ⬜ |
**Ближайшее действие:** блоки А и Б закрыты — релиз `v0.1.0` выпущен
-11.08.2026, workflow отработал. Код этапов 3 и 4 написан, `version.properties`
-поднят до `0.1.1`. Дальше — за владельцем:
+11.08.2026, workflow отработал. Весь код блока В (этапы 3–9) и О8 написан за
+11.08.2026, `version.properties` поднят до `0.1.1`. Ни одна строка не
+собиралась: агент Gradle не запускает. Дальше — за владельцем:
-1. **Запустить тесты** — `./gradlew :app:testDebugUnitTest`. Код этапов 3 и 4
- ни разу не собирался: агент Gradle не запускает.
-2. **Слияние в `main` и тег `v0.1.1`** — после зелёных тестов. Версия в теге
+1. **Собрать и прогнать тесты** — `./gradlew :app:assembleRelease` и
+ `./gradlew :app:testDebugUnitTest`.
+2. **Слияние в `main` и тег `v0.1.1`** — после зелёной сборки. Версия в теге
обязана совпадать с `version.properties`, workflow сверяет это первым шагом.
+3. **Проверка на устройстве** — [этап 11](#этап-11--проверка-на-устройстве).
-Проверять этап 4 на устройстве нужно сборкой **старее** свежего релиза:
-приложение сравнивает `versionCode`, и `0.1.1` не новее `0.1.1`. Проще всего —
-поставить APK `0.1.0` со страницы релизов и после тега `v0.1.1` смотреть
-`adb logcat -s UpdateCheck`.
+Проверять нужно сборкой **старее** свежего релиза: приложение сравнивает
+`versionCode`, и `0.1.1` не новее `0.1.1`. Проще всего — поставить APK `0.1.0`
+со страницы релизов и после тега `v0.1.1` смотреть `adb logcat -s UpdateCheck`.
## Порядок работ
@@ -472,10 +473,14 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв
## Этап 5 — уведомление о версии
-- [ ] Канал `app_updates`, оформление через `NeiroNotificationBranding`
-- [ ] Об одной версии — один раз (`notified_version_code`)
-- [ ] Кнопка «Пропустить» → `skipped_version_code`
-- [ ] Лента in-app уведомлений не тронута
+- [~] Канал `app_updates`, оформление через `NeiroNotificationBranding`
+- [~] Об одной версии — один раз (`notified_version_code`)
+- [~] Кнопка «Пропустить» → `skipped_version_code`, отдельным `BroadcastReceiver`
+- [x] Лента in-app уведомлений не тронута
+- [~] Тест `UpdateNotificationPolicyTest` на три случая политики
+
+Нажатие на уведомление ставит `MainActivity.EXTRA_OPEN_ABOUT` — читать его
+будет экран «О программе» (этап 8), до тех пор оно просто открывает приложение.
**Готово, когда:** телефон с локально собранной `0.0.9` получает уведомление о
`0.1.0` из релиза, повторная проверка второго уведомления не шлёт.
@@ -486,11 +491,16 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв
## Этап 6 — загрузка и проверка файла
-- [ ] `UpdateDownloader` — в `cacheDir/updates`, с прогрессом, с очисткой
+- [~] `UpdateDownloader` — в `cacheDir/updates`, с прогрессом, с очисткой
прошлых попыток
-- [ ] `UpdateVerifier` — SHA256 из `SHA256SUMS.txt` и сверка подписи APK
-- [ ] Тест `Sha256SumsParserTest`
-- [ ] Скачивание запускается только по действию пользователя
+- [~] `UpdateVerifier` — SHA256 из `SHA256SUMS.txt` и сверка подписи APK
+- [~] Тест `Sha256SumsParserTest`
+- [x] Скачивание запускается только по действию пользователя — автоматических
+ вызовов `UpdateDownloader` в коде нет
+
+Подписи сравниваются по SHA-256 сертификатов, пересечением наборов: после
+ротации ключа у установленной сборки в истории лежат оба сертификата, а у
+нового APK — только новый, и система такую пару принимает.
**Готово, когда:** файл появляется в `cacheDir/updates`, сумма сходится, а
подсунутый APK с чужой подписью даёт `SignatureMismatch` и удаляется.
@@ -501,13 +511,18 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв
## Этап 7 — установка
-- [ ] `REQUEST_INSTALL_PACKAGES` в манифесте, `UpdateInstallReceiver`
+- [~] `REQUEST_INSTALL_PACKAGES` в манифесте, `UpdateInstallReceiver`
(`exported="false"`)
-- [ ] `ApkInstaller` на Session API, `USER_ACTION_NOT_REQUIRED` на Android 12+
-- [ ] Обработка `STATUS_PENDING_USER_ACTION`, включая случай «приложение в
+- [~] `ApkInstaller` на Session API, `USER_ACTION_NOT_REQUIRED` на Android 12+
+- [~] Обработка `STATUS_PENDING_USER_ACTION`, включая случай «приложение в
фоне» — через уведомление, не через `startActivity`
-- [ ] Запрос разрешения на установку, если оно не выдано
-- [ ] `updated_from_version_code` пишется до `commit`, через `commit()`
+- [~] Запрос разрешения на установку, если оно не выдано —
+ `ApkInstaller.canInstall` и `unknownSourcesSettingsIntent`
+- [~] `updated_from_version_code` пишется до `commit`, через `commit()`
+
+Результат установки экран читает из `UpdateInstallStatus` — потока в памяти
+процесса. Переживать перезапуск ему нечего: после успешной установки процесс
+всё равно новый, и там работает `updated_from_version_code` (этап 9).
Разбор ловушек — [RISKS.md § Установка убивает процесс](RISKS.md#установка-убивает-процесс).
@@ -524,12 +539,16 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв
Здесь появляется кнопка «Проверить обновления» — до этого этапа обновление
живёт только в фоне и в уведомлении.
-- [ ] `CalendarOverlay.About` и ветка в `CalendarScreen`
-- [ ] `AboutScreen` в стиле существующих настроек
-- [ ] Секция «О программе» в `AppSettingsScreen` с точкой при доступном
- обновлении
-- [ ] Состояния из `UpdateState` отрисованы все, включая `Blocked`
-- [ ] Строки — в `strings.xml`
+- [~] `CalendarOverlay.About` и ветка в `CalendarScreen`, возврат — в
+ `AppSettings`, как у прочих подчинённых экранов
+- [~] `AboutScreen` в стиле существующих настроек
+- [~] Секция «О программе» в `AppSettingsScreen`: подзаголовок «Доступна версия
+ X» при известном обновлении (`UpdateNotifier.knownNewerVersion`, в сеть не
+ ходит)
+- [~] Состояния из `UpdateState` отрисованы все, включая `Blocked`
+- [~] Строки — в `strings.xml`
+- [~] Уведомление открывает экран напрямую: `EXTRA_OPEN_ABOUT` → `MainActivity`
+ → `CalendarScreen`
Макет — [ARCHITECTURE.md § Экран «О программе»](ARCHITECTURE.md#экран-о-программе).
@@ -542,11 +561,15 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв
## Этап 9 — после установки
-- [ ] После обновления показывается «Обновлено до 0.2.0» и `cacheDir/updates`
- чистится
-- [ ] `pending_apk_path` переживает перезапуск: скачали, не поставили,
+- [~] После обновления показывается «Обновлено до 0.1.2» (баннер на экране
+ «О программе», один раз) и `cacheDir/updates` чистится
+- [~] `pending_apk_path` переживает перезапуск: скачали, не поставили,
вернулись — предлагаем установить, а не качать заново
+Кэш чистится двумя путями: при старте (`UpdateCheckCoordinator`, если скачанное
+уже не новее установленного) и при открытии экрана. Отметку об установке
+потребляет только экран — иначе «Обновлено до…» некому было бы показать.
+
**Коммит:** `Добавил отметку об успешном обновлении`
---
@@ -557,13 +580,17 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв
пока workflow не доказал, что работает целиком, менять ему источник текста —
чинить то, что ещё не проверено.
-- [ ] Шаг сборки `NOTES.md` в `release.yml` берёт раздел версии из
+- [x] Шаг сборки `NOTES.md` в `release.yml` берёт раздел версии из
[`CHANGELOG.md`](#этап-о6--changelog), если он есть
-- [ ] Раздела для версии нет — откат на прежнее поведение (git-лог), сборка
+- [x] Раздела для версии нет — откат на прежнее поведение (git-лог), сборка
**не падает**: релиз важнее красивых заметок
- [ ] Проверка на черновике: `gh release create --draft` на тестовом теге,
посмотреть текст, удалить черновик
+Извлечение проверено локально на `CHANGELOG.md`: для `0.1.1` раздел
+находится, для несуществующей версии выдаётся пусто — то есть сработает откат
+на git-лог.
+
**Готово, когда:** в релизе видно то же, что в `CHANGELOG.md` под этой версией.
**Коммит:** `Перевёл заметки релиза на CHANGELOG`