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`