Compare commits

...
Author SHA1 Message Date
Agzamus dea27bc8f1 ci: fixed workflow old apt jdk17 repo 2026-09-07 00:35:50 +03:00
Agzamus bafebfd253 fix: no auto download if updated disabled 2026-09-07 00:31:34 +03:00
Agzamus f19d65abb5 feat: update request when need 2026-09-06 17:23:55 +03:00
Agzamus 889c57bbba feat: autoupdater 2026-09-06 12:38:00 +03:00
Agzamus ec6a7174e8 feat: icon & name 2026-09-06 02:47:40 +03:00
Agzamus 014896aaea ui: no more empty lines in schedule 2026-09-05 00:21:39 +03:00
Agzamus 9f6cf3ef30 fix: #1 - text blocks by words 2026-09-05 00:13:38 +03:00
21 changed files with 837 additions and 48 deletions
+11 -2
View File
@@ -29,10 +29,19 @@ jobs:
- name: Set up JDK 17
# Ставим JDK вручную и явно прописываем JAVA_HOME.
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
# JDK качаем из архива Adoptium напрямую: образ раннера — Debian bullseye,
# его репозитории уже в архиве, apt-установка openjdk падает с 404.
run: |
set -euxo pipefail
apt-get update
apt-get install -y openjdk-17-jdk-headless unzip wget
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
apt-get install -y --no-install-recommends wget unzip
mkdir -p /opt/jdk
wget -qO /tmp/jdk17.tar.gz \
"https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
tar -xzf /tmp/jdk17.tar.gz -C /opt/jdk --strip-components=1
echo "JAVA_HOME=/opt/jdk" >> "$GITHUB_ENV"
echo "/opt/jdk/bin" >> "$GITHUB_PATH"
/opt/jdk/bin/java -version
- name: Install Android SDK
# Устанавливаем command-line tools и нужные компоненты SDK,
+12 -1
View File
@@ -3,13 +3,14 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".VyatsuApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:icon="@drawable/icon"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -25,6 +26,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
+31
View File
@@ -5,6 +5,10 @@ import agzam.vyatsu.ui.groups.GroupPickerScreen
import agzam.vyatsu.ui.groups.GroupPickerViewModel
import agzam.vyatsu.ui.home.HomeScreen
import agzam.vyatsu.ui.schedule.ScheduleViewModel
import agzam.vyatsu.ui.settings.UpdateViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -20,8 +24,33 @@ fun AppRoot() {
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
val state by scheduleViewModel.uiState.collectAsState()
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
if (showFirstRunDialog) {
AlertDialog(
onDismissRequest = { updateViewModel.onFirstRunDecision(false) },
title = { Text("Автоматические обновления") },
text = {
Text(
"Разрешить автоматически проверять и устанавливать " +
"новые версии приложения?"
)
},
confirmButton = {
TextButton(onClick = { updateViewModel.onFirstRunDecision(true) }) {
Text("Разрешить")
}
},
dismissButton = {
TextButton(onClick = { updateViewModel.onFirstRunDecision(false) }) {
Text("Не надо")
}
}
)
}
if (state.noGroup) {
GroupPickerScreen(
@@ -32,6 +61,7 @@ fun AppRoot() {
}
HomeScreen(
scheduleViewModel = scheduleViewModel,
updateViewModel = updateViewModel,
onSwitchGroup = { scheduleViewModel.switchGroup() }
)
}
@@ -43,3 +73,4 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
@@ -10,6 +10,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as VyatsuApplication).container
container.appUpdater.autoCheckAndInstall()
setContent {
VyatsuScheduleTheme {
AppRoot()
@@ -27,4 +27,16 @@ interface PreferencesDao {
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String)
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
suspend fun autoUpdateEnabled(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateEnabled', :enabled)")
suspend fun setAutoUpdateEnabled(enabled: String)
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateAsked'")
suspend fun autoUpdateAsked(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateAsked', :asked)")
suspend fun setAutoUpdateAsked(asked: String)
}
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
private val svgBuilder = StringBuilder()
private val currentPath = StringBuilder()
private val tableSvg = StringBuilder()
val tableSvg = StringBuilder()
private val currentSubPathLines = mutableListOf<PdfLine>()
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val width = mediaBox.width
val height = mediaBox.height
return """
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
<g>${svgBuilder}</g>
<g>${tableSvg}</g>
</svg>
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val bottom = ys[ii+1]
cells.add(TableCell(left, top, right-left, bottom-top))
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
}
columns.add(TableColumn(left, right-left, cells))
}
@@ -52,12 +52,6 @@ object PdfScheduleParser {
svg.processPage(page)
val cols = svg.asTable()
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
for(line in svg.asSvg().split("\n")) {
Log.v(SVG_TAG, line)
}
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
// Парсинг текста
@@ -67,6 +61,11 @@ object PdfScheduleParser {
textStripper.getText(doc)
val texts = textStripper.blocks
for(text in texts) {
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
}
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
@@ -114,7 +113,10 @@ object PdfScheduleParser {
group = content(groupCell)
continue
}
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
if(day.size != 2) {
Log.w(TAG, "Day data is not in format <day> <date>: $day")
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
}
val date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>()
@@ -161,7 +163,7 @@ object PdfScheduleParser {
val type = LessonType.fromRaw(typeMatch.first().value.trim())
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null
Log.d(TAG, subject + " " + group)
Log.d(TAG, "$subject $group")
if(group != null && subject.startsWith("$group,")) {
subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject)
@@ -192,6 +194,18 @@ object PdfScheduleParser {
for (text in texts.filter { it.rotation != 0f }) {
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
}
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
for(line in svg.asSvg().split("\n")) {
if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length)
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
continue
}
Log.v(SVG_TAG, line)
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
}
}
} catch (e: Exception) {
Log.e(TAG, "Parsing error", e)
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
* Парсер блоков текста из PDF
* @author Agzam
*/
class PdfTextParser : PDFTextStripper() {
data class TextBlock(
@@ -25,21 +26,51 @@ class PdfTextParser : PDFTextStripper() {
}
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
val first = textPositions.first()
val last = textPositions.last()
if (textPositions.isNullOrEmpty()) return
val matrix = first.textMatrix
val rotation = first.dir
val currentWordChars = mutableListOf<TextPosition>()
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
for (tp in textPositions) {
val unicode = tp.unicode
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
addWordBlock(currentWordChars)
currentWordChars.clear()
} else {
currentWordChars.add(tp)
}
}
addWordBlock(currentWordChars)
}
private fun addWordBlock(wordChars: List<TextPosition>) {
if (wordChars.isEmpty()) return
val first = wordChars.first()
val last = wordChars.last()
val wordText = wordChars.joinToString("") { it.unicode }.trim()
if (wordText.isEmpty()) return
val matrix = first.textMatrix
val rotation = first.dir
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
blocks.add(
TextBlock(
text = wordText,
x = x,
y = currentPage.mediaBox.height - y - height,
width = totalWidth,
height = height,
rotation = rotation
)
)
}
}
@@ -0,0 +1,56 @@
package agzam.vyatsu.data.remote.update
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
@JsonClass(generateAdapter = true)
data class GiteaRelease(
@Json(name = "tag_name") val tagName: String?,
val name: String?,
val body: String?,
val assets: List<GiteaAsset> = emptyList()
)
@JsonClass(generateAdapter = true)
data class GiteaAsset(
val name: String?,
@Json(name = "browser_download_url") val browserDownloadUrl: String?
)
class GiteaReleaseApi(
private val client: OkHttpClient,
private val baseUrl: String = "https://vantablacktea.ddns.net",
private val repoPath: String = "agzam/vyatsu-schedule"
) {
private val moshi = Moshi.Builder().build()
private val releaseAdapter = moshi.adapter(GiteaRelease::class.java)
/**
* Возвращает последний релиз или null, если релизов ещё нет (404).
*/
suspend fun latestRelease(): GiteaRelease? {
val url = "$baseUrl/api/v1/repos/$repoPath/releases/latest"
val request = Request.Builder().url(url).get().build()
return client.newCall(request).execute().use { resp ->
when {
resp.code == 404 -> null
!resp.isSuccessful -> throw IOException("HTTP ${resp.code} при запросе релиза")
else -> {
val body = resp.body ?: throw IOException("Пустой ответ сервера")
releaseAdapter.fromJson(body.source())
}
}
}
}
fun apkDownloadUrl(release: GiteaRelease): String? =
release.assets
.firstOrNull { it.name?.endsWith(".apk", ignoreCase = true) == true }
?.browserDownloadUrl
}
@@ -13,4 +13,10 @@ class PreferencesRepositoryImpl(
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
override suspend fun autoUpdateAsked(): Boolean = dao.autoUpdateAsked() == "true"
override suspend fun setAutoUpdateAsked(asked: Boolean) = dao.setAutoUpdateAsked(asked.toString())
}
@@ -9,6 +9,7 @@ import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import agzam.vyatsu.update.AppUpdater
import android.content.Context
import androidx.room.Room
@@ -37,4 +38,8 @@ class AppContainer(context: Context) {
val preferencesRepository: PreferencesRepository by lazy {
PreferencesRepositoryImpl(database.preferencesDao())
}
val appUpdater: AppUpdater by lazy {
AppUpdater(context.applicationContext, preferencesRepository)
}
}
@@ -8,4 +8,10 @@ interface PreferencesRepository {
suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String)
suspend fun autoUpdateEnabled(): Boolean
suspend fun setAutoUpdateEnabled(enabled: Boolean)
suspend fun autoUpdateAsked(): Boolean
suspend fun setAutoUpdateAsked(asked: Boolean)
}
@@ -4,12 +4,15 @@ import agzam.vyatsu.ui.info.BuildingsScreen
import agzam.vyatsu.ui.info.CallsScreen
import agzam.vyatsu.ui.schedule.ScheduleScreen
import agzam.vyatsu.ui.schedule.ScheduleViewModel
import agzam.vyatsu.ui.settings.SettingsScreen
import agzam.vyatsu.ui.settings.UpdateViewModel
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
@@ -25,12 +28,14 @@ import androidx.compose.ui.Modifier
private enum class HomeTab(val label: String) {
SCHEDULE("Расписание"),
CALLS("Звонки"),
BUILDINGS("Корпуса")
BUILDINGS("Корпуса"),
SETTINGS("Настройки")
}
@Composable
fun HomeScreen(
scheduleViewModel: ScheduleViewModel,
updateViewModel: UpdateViewModel,
onSwitchGroup: () -> Unit
) {
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
@@ -48,6 +53,7 @@ fun HomeScreen(
HomeTab.SCHEDULE -> Icons.Default.DateRange
HomeTab.CALLS -> Icons.Default.Info
HomeTab.BUILDINGS -> Icons.Default.Home
HomeTab.SETTINGS -> Icons.Default.Settings
},
contentDescription = t.label
)
@@ -67,6 +73,7 @@ fun HomeScreen(
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
HomeTab.CALLS -> CallsScreen()
HomeTab.BUILDINGS -> BuildingsScreen()
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
}
}
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.clickable
@@ -285,27 +286,48 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
} else {
slot.lessons.forEach { lesson ->
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
lesson.type.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
style = MaterialTheme.typography.bodyLarge
)
Text(
listOfNotNull(
lesson.subgroup,
lesson.teacher,
lesson.room
var any = false
if(lesson.type != LessonType.OTHER) {
Text(
lesson.type.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if(subject.isNotEmpty()) {
Text(
subject,
style = MaterialTheme.typography.bodyLarge
)
any = true
}
val meta = listOfNotNull(
lesson.subgroup,
lesson.teacher,
lesson.room
).filter { it.isNotBlank() }.joinToString(" · ")
if(meta.isNotEmpty()) {
Text(
meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
if(!any) {
Text(
"-",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,152 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.update.UpdateState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(viewModel: UpdateViewModel) {
val autoEnabled by viewModel.autoEnabled.collectAsState()
val updateState by viewModel.updateState.collectAsState()
LazyColumn(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
) {
item { SectionHeader("Обновления") }
item {
SettingsItem(
title = "Автоматические обновления",
subtitle = "Проверять и устанавливать новые версии при запуске",
trailing = {
Switch(
checked = autoEnabled,
onCheckedChange = { viewModel.setAutoEnabled(it) }
)
}
)
SettingsDivider()
}
item {
SettingsItem(
title = "Проверить наличие обновлений",
subtitle = updateStatusText(updateState, autoEnabled),
onClick = { viewModel.checkNow() }
)
SettingsDivider()
}
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
item {
SettingsItem(
title = "Установить обновление",
subtitle = installHint(updateState, viewModel),
onClick = {
when (updateState) {
is UpdateState.Available -> viewModel.downloadAndInstall()
is UpdateState.ReadyToInstall -> viewModel.installDownloadedApk()
else -> {}
}
}
)
SettingsDivider()
}
}
item { SectionHeader("О приложении") }
item {
SettingsItem(
title = "Версия",
trailing = {
Text(
BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
SettingsDivider()
}
}
}
private fun updateStatusText(state: UpdateState, autoEnabled: Boolean): String = when (state) {
is UpdateState.Idle -> "Проверить сейчас"
is UpdateState.Checking -> "Проверка…"
is UpdateState.UpToDate -> "Установлена последняя версия"
is UpdateState.Available ->
if (autoEnabled) "Доступна версия ${state.version}"
else "Доступна версия ${state.version} — включите автообновления для установки"
is UpdateState.Downloading -> "Скачивание: ${state.progress?.let { "$it%" } ?: "…"}"
is UpdateState.ReadyToInstall -> "Обновление скачано, нажмите для установки"
is UpdateState.Failed -> "Ошибка: ${state.message}"
}
private fun installHint(state: UpdateState, viewModel: UpdateViewModel): String = when {
state is UpdateState.ReadyToInstall && !viewModel.canRequestInstalls() ->
"Разрешите установку приложений из этого источника"
else -> "Нажмите, чтобы продолжить"
}
@Composable
private fun SectionHeader(title: String) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun SettingsItem(
title: String,
subtitle: String? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
@Composable
private fun SettingsDivider() {
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
@@ -0,0 +1,58 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.update.AppUpdater
import agzam.vyatsu.update.UpdateState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class UpdateViewModel(
private val updater: AppUpdater,
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
val updateState: StateFlow<UpdateState> = updater.state
private val _autoEnabled = MutableStateFlow(false)
val autoEnabled: StateFlow<Boolean> = _autoEnabled.asStateFlow()
private val _showFirstRunDialog = MutableStateFlow(false)
val showFirstRunDialog: StateFlow<Boolean> = _showFirstRunDialog.asStateFlow()
init {
viewModelScope.launch {
_autoEnabled.value = preferencesRepository.autoUpdateEnabled()
_showFirstRunDialog.value = !preferencesRepository.autoUpdateAsked()
}
}
fun setAutoEnabled(enabled: Boolean) {
_autoEnabled.value = enabled
updater.setAutoEnabled(enabled)
if (enabled && !updater.canRequestInstalls()) {
updater.openUnknownSourcesSettings()
}
}
fun onFirstRunDecision(enable: Boolean) {
_showFirstRunDialog.value = false
viewModelScope.launch {
preferencesRepository.setAutoUpdateAsked(true)
}
setAutoEnabled(enable)
}
fun checkNow() = updater.checkForUpdate()
fun downloadAndInstall() = updater.downloadAndInstall()
fun installDownloadedApk() = updater.installDownloadedApk()
fun canRequestInstalls() = updater.canRequestInstalls()
fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
}
@@ -0,0 +1,217 @@
package agzam.vyatsu.update
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.data.remote.update.GiteaReleaseApi
import agzam.vyatsu.domain.repository.PreferencesRepository
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data object UpToDate : UpdateState
data class Available(val version: String, val notes: String?) : UpdateState
data class Downloading(val progress: Int?) : UpdateState
data object ReadyToInstall : UpdateState
data class Failed(val message: String) : UpdateState
}
class AppUpdater(
private val context: Context,
private val preferencesRepository: PreferencesRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
private val api = GiteaReleaseApi(client)
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
val state: StateFlow<UpdateState> = _state.asStateFlow()
private var job: Job? = null
private var pendingUpdate: AvailableUpdate? = null
private var downloadedApk: File? = null
fun autoCheckAndInstall() {
if (job?.isActive == true) return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching {
val update = check() ?: return@launch
install(download(update))
}.onFailure(::onUpdateFailed)
}
}
fun checkForUpdate() {
if (job?.isActive == true) return
job = scope.launch {
runCatching { check() }.onFailure(::onUpdateFailed)
}
}
fun downloadAndInstall() {
if (job?.isActive == true) return
val update = pendingUpdate ?: return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
}
}
fun installDownloadedApk() {
val apk = downloadedApk ?: return
install(apk)
}
fun setAutoEnabled(enabled: Boolean) {
if (!enabled) {
job?.cancel()
if (_state.value is UpdateState.Downloading || _state.value is UpdateState.Checking) {
_state.value = UpdateState.Idle
}
}
scope.launch {
preferencesRepository.setAutoUpdateEnabled(enabled)
}
}
fun canRequestInstalls(): Boolean =
context.packageManager.canRequestPackageInstalls()
fun openUnknownSourcesSettings() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
private suspend fun check(): AvailableUpdate? {
_state.value = UpdateState.Checking
val release = api.latestRelease()
if (release == null) {
_state.value = UpdateState.UpToDate
return null
}
val remoteVersion = release.tagName?.trimStart('v', 'V')
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) {
_state.value = UpdateState.UpToDate
return null
}
val apkUrl = api.apkDownloadUrl(release)
?: throw IOException("В релизе $remoteVersion нет APK-файла")
val update = AvailableUpdate(version = remoteVersion, apkUrl = apkUrl)
pendingUpdate = update
_state.value = UpdateState.Available(update.version, release.body)
return update
}
private suspend fun download(update: AvailableUpdate): File {
_state.value = UpdateState.Downloading(null)
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val target = File(dir, "update-${update.version}.apk")
downloadedApk?.delete()
val response = client.newCall(Request.Builder().url(update.apkUrl).get().build()).execute()
response.use { resp ->
if (!resp.isSuccessful) throw IOException("HTTP ${resp.code} при скачивании")
val body = resp.body ?: throw IOException("Пустой ответ при скачивании")
val contentLength = body.contentLength()
body.byteStream().use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE_BYTES)
var readTotal = 0L
while (true) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
readTotal += read
if (contentLength > 0) {
_state.value = UpdateState.Downloading((readTotal * 100 / contentLength).toInt())
}
}
}
}
}
if (target.length() == 0L) {
target.delete()
throw IOException("Скачанный файл пуст")
}
downloadedApk = target
_state.value = UpdateState.ReadyToInstall
return target
}
private fun install(apk: File) {
if (!canRequestInstalls()) {
return
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apk
)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { Log.e(TAG, "Не удалось запустить установку", it) }
}
private fun onUpdateFailed(error: Throwable) {
if (error is CancellationException) throw error
Log.e(TAG, "Ошибка обновления", error)
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
}
private data class AvailableUpdate(val version: String, val apkUrl: String)
private fun isNewerVersion(remote: String, local: String): Boolean {
val remoteParts = remote.split('.').map { it.toLongOrNull() ?: 0L }
val localParts = local.split('.').map { it.toLongOrNull() ?: 0L }
val size = maxOf(remoteParts.size, localParts.size)
for (i in 0 until size) {
val r = remoteParts.getOrElse(i) { 0L }
val l = localParts.getOrElse(i) { 0L }
if (r != l) return r > l
}
return false
}
private companion object {
const val TAG = "AppUpdate"
const val BUFFER_SIZE_BYTES = 8 * 1024
}
}
+109
View File
@@ -0,0 +1,109 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="100dp"
android:height="100dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M1,6L1,18L19,18L19,6"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF5581D5"/>
<item android:offset="1" android:color="#FF4063CD"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF5581D5"/>
<item android:offset="1" android:color="#FF4063CD"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M19,18L1,18C12,18 19,14 19,6Z"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF6598E3"/>
<item android:offset="1" android:color="#FF74A2E6"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF6598E3"/>
<item android:offset="1" android:color="#FF74A2E6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M4,4C4,2 6,2 6,4L14,4C14,2 16,2 16,4L19,4L19,6L1,6L1,4Z"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="2.5"
android:endX="19"
android:endY="2.5"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="2.5"
android:endX="19"
android:endY="2.5"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M3,8L17,8C18,8 18,9 17,9L3,9C2,9 2,8 3,8M3,11L14,11C15,11 15,12 14,12L3,12C2,12 2,11 3,11M4,14L12,14C13,14 13,15 12,15L5,15C4,15 4,14 5,14M3,14C4,14 4,15 3,15C2,15 2,14 3,14"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="2.25"
android:startY="8"
android:endX="17.75"
android:endY="8"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
</path>
</vector>
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100px" height="100px" viewBox="-10 -10 20 20">
<defs>
<linearGradient id="accent">
<stop offset="0%" stop-color="#4260c4" />
<stop offset="100%" stop-color="#0e2cb0" />
</linearGradient>
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5581d5" />
<stop offset="100%" stop-color="#4063cd" />
</linearGradient>
<linearGradient id="bga" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6598e3" />
<stop offset="100%" stop-color="#74a2e6" />
</linearGradient>
</defs>
<g stroke-width="0" stroke-linejoin="round" stroke-linecap="round">
<!-- <path stroke="#111" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/> -->
<path stroke="url(#bg)" fill="url(#bg)" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/>
<path stroke="url(#bga)" fill="url(#bga)" d="M 9 8 L -9 8 C 2 8 9 4 9 -4 Z"/>
<!-- <path stroke="#111" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/> -->
<g stroke="url(#accent)">
<path fill="url(#accent)" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/>
<path fill="url(#accent)" stroke="none" d="M -7 -2 L 7 -2 C 8 -2 8 -1 7 -1 L -7 -1 C -8 -1 -8 -2 -7 -2 M -7 1 L 4 1 C 5 1 5 2 4 2 L -7 2 C -8 2 -8 1 -7 1 M -6 4 L 2 4 C 3 4 3 5 2 5 L -5 5 C -6 5 -6 4 -5 4 M -7 4 C -6 4 -6 5 -7 5 C -8 5 -8 4 -7 4"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">VyatsuSchedule</string>
<string name="app_name">ВятГУ Расписание</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="updates"
path="updates/" />
</paths>