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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@

### Добавлено

- **Проверка новых версий** — приложение раз в сутки спрашивает GitHub, не
вышла ли версия новее, и при открытии, если давно не спрашивало. Пока
проверка молчаливая: сообщение о найденной версии и обновление одним
нажатием появятся в следующих выпусках.
- **Самообновление** — приложение раз в сутки спрашивает GitHub, не вышла ли
версия новее, и сообщает об этом уведомлением. По нажатию «Обновить» само
скачивает файл, сверяет контрольную сумму и подпись и ставит новую версию
поверх старой: настройки, календарь и архив остаются на месте. Уведомление
можно пропустить — тогда о версии больше не напомнят.
- **Экран «О программе»** — в настройках приложения: текущая версия, кнопка
«Проверить обновления» и переключатель автоматической проверки.

## [0.1.0] — 2026-08-11

Expand Down
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,9 @@
[как именно](#обновления). Что изменилось от версии к версии —
[CHANGELOG.md](CHANGELOG.md).

> Самообновление внутри приложения ещё пишется по
> [плану](docs/updater/TASKS.md): проверку новых версий приложение уже делает,
> а скачивание и установку одним нажатием — пока нет. До этого момента новая
> версия ставится файлом со страницы релизов, поверх старой, с сохранением
> данных.
> Начиная с 0.1.1 приложение обновляет себя само: замечает новую версию,
> сообщает уведомлением и по согласию ставит её одним нажатием. Версии старше
> обновляются файлом со страницы релизов — поверх старой, данные сохраняются.

Сборка из исходников — Android Studio или `./gradlew assembleDebug`; что нужно
положить рядом, описано в [быстром старте](#быстрый-старт-разработка).
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--
Самообновление: приложение отдаёт скачанный APK системному установщику.
На API 26+ одного разрешения мало — пользователь ещё раз разрешает
установку из Neiro в системных настройках (docs/updater/ARCHITECTURE.md).
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<application
android:name=".NeiroApplication"
Expand Down Expand Up @@ -41,6 +47,22 @@
</intent-filter>
</receiver>

<receiver
android:name=".update.UpdateNotificationReceiver"
android:exported="false">
<intent-filter>
<action android:name="ru.greemlab.neiro.update.SKIP_VERSION" />
</intent-filter>
</receiver>

<receiver
android:name=".update.UpdateInstallReceiver"
android:exported="false">
<intent-filter>
<action android:name="ru.greemlab.neiro.update.INSTALL_STATUS" />
</intent-filter>
</receiver>

<service
android:name=".push.NeiroFirebaseMessagingService"
android:exported="false">
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/ru/greemlab/neiro/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(null)
private var highlightSlotKey by mutableStateOf<String?>(null)
private var notificationDeepLinkVersion by mutableIntStateOf(0)
private var openAbout by mutableStateOf(false)

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
Expand All @@ -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)
}
Expand All @@ -75,6 +81,7 @@ class MainActivity : ComponentActivity() {
openDateFromNotification = openDate,
highlightSlotKeyFromNotification = highlightSlotKey,
notificationDeepLinkVersion = deepLinkVersion,
openAboutFromNotification = openAbout,
)
RequestNotificationPermissionIfNeeded()
}
Expand All @@ -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) {
Expand All @@ -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++
}
}
Expand Down Expand Up @@ -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()
Expand All @@ -174,6 +187,7 @@ private fun NeiroApp(
openDateFromNotification = openDateFromNotification,
highlightSlotKeyFromNotification = highlightSlotKeyFromNotification,
notificationDeepLinkVersion = notificationDeepLinkVersion,
openAboutFromNotification = openAboutFromNotification,
)
}
}
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/ru/greemlab/neiro/ui/profile/ProfileContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +78,7 @@ fun ProfileContent(
onOpenSettings: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenYClients: () -> Unit = {},
onOpenAbout: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val profile by profileViewModel.userProfile.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -134,6 +136,7 @@ fun ProfileContent(
onOpenSettings = onOpenSettings,
onOpenAppSettings = onOpenAppSettings,
onOpenYClients = onOpenYClients,
onOpenAbout = onOpenAbout,
onSyncNow = syncViewModel::syncAllThroughCurrentMonth,
onDevLogin = syncViewModel::devLogin,
onDevSync = syncViewModel::devSyncAll,
Expand Down Expand Up @@ -164,6 +167,7 @@ private fun ProfileContentImpl(
onOpenSettings: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenYClients: () -> Unit = {},
onOpenAbout: () -> Unit = {},
onSyncNow: () -> Unit = {},
onDevLogin: () -> Unit = {},
onDevSync: () -> Unit = {},
Expand Down Expand Up @@ -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)) {
Expand All @@ -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),
) {
Expand Down
48 changes: 46 additions & 2 deletions app/src/main/java/ru/greemlab/neiro/ui/screens/CalendarScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
},
)
}
}
Expand Down Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -584,6 +626,7 @@ private val OverlaySaver = Saver<CalendarOverlay, String>(
CalendarOverlay.RegistrationPrompt -> "registration"
CalendarOverlay.ProfitDetails -> "profit_details"
CalendarOverlay.ProfitSettings -> "profit_settings"
CalendarOverlay.About -> "about"
CalendarOverlay.LessonsDetails -> "lessons"
CalendarOverlay.DayDetails -> "day"
CalendarOverlay.Notifications -> "notifications"
Expand All @@ -599,6 +642,7 @@ private val OverlaySaver = Saver<CalendarOverlay, String>(
"profit" -> CalendarOverlay.ProfitDetails
"profit_details" -> CalendarOverlay.ProfitDetails
"profit_settings" -> CalendarOverlay.ProfitSettings
"about" -> CalendarOverlay.About
"lessons" -> CalendarOverlay.LessonsDetails
"day" -> CalendarOverlay.DayDetails
"notifications" -> CalendarOverlay.Notifications
Expand Down
Loading
Loading