diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eca2339..3a909a3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..d06a517
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/agzam/vyatsu/AppRoot.kt b/app/src/main/java/agzam/vyatsu/AppRoot.kt
index 90b21e2..4df5bb2 100644
--- a/app/src/main/java/agzam/vyatsu/AppRoot.kt
+++ b/app/src/main/java/agzam/vyatsu/AppRoot.kt
@@ -5,6 +5,7 @@ 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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -20,6 +21,7 @@ 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()
@@ -32,6 +34,7 @@ fun AppRoot() {
}
HomeScreen(
scheduleViewModel = scheduleViewModel,
+ updateViewModel = updateViewModel,
onSwitchGroup = { scheduleViewModel.switchGroup() }
)
}
@@ -43,3 +46,4 @@ private class SimpleFactory(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) }
diff --git a/app/src/main/java/agzam/vyatsu/MainActivity.kt b/app/src/main/java/agzam/vyatsu/MainActivity.kt
index a9ff8ad..a78557e 100644
--- a/app/src/main/java/agzam/vyatsu/MainActivity.kt
+++ b/app/src/main/java/agzam/vyatsu/MainActivity.kt
@@ -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()
diff --git a/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt b/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt
index a5ac5f0..2986088 100644
--- a/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt
+++ b/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt
@@ -27,4 +27,10 @@ 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)
}
diff --git a/app/src/main/java/agzam/vyatsu/data/remote/update/GiteaReleaseApi.kt b/app/src/main/java/agzam/vyatsu/data/remote/update/GiteaReleaseApi.kt
new file mode 100644
index 0000000..c4726ec
--- /dev/null
+++ b/app/src/main/java/agzam/vyatsu/data/remote/update/GiteaReleaseApi.kt
@@ -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 = 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
+}
diff --git a/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt
index 5ad238e..51bdc98 100644
--- a/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt
+++ b/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt
@@ -13,4 +13,7 @@ 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() != "false"
+ override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
}
diff --git a/app/src/main/java/agzam/vyatsu/di/AppContainer.kt b/app/src/main/java/agzam/vyatsu/di/AppContainer.kt
index 11c7ab1..f2486d0 100644
--- a/app/src/main/java/agzam/vyatsu/di/AppContainer.kt
+++ b/app/src/main/java/agzam/vyatsu/di/AppContainer.kt
@@ -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)
+ }
}
diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt
index c4a6307..aa7fca9 100644
--- a/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt
+++ b/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt
@@ -8,4 +8,7 @@ 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)
}
diff --git a/app/src/main/java/agzam/vyatsu/ui/home/HomeScreen.kt b/app/src/main/java/agzam/vyatsu/ui/home/HomeScreen.kt
index 16a476c..8310663 100644
--- a/app/src/main/java/agzam/vyatsu/ui/home/HomeScreen.kt
+++ b/app/src/main/java/agzam/vyatsu/ui/home/HomeScreen.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/agzam/vyatsu/ui/settings/SettingsScreen.kt b/app/src/main/java/agzam/vyatsu/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..19da39e
--- /dev/null
+++ b/app/src/main/java/agzam/vyatsu/ui/settings/SettingsScreen.kt
@@ -0,0 +1,150 @@
+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),
+ onClick = { viewModel.checkNow() }
+ )
+ SettingsDivider()
+ }
+ if (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): String = when (state) {
+ is UpdateState.Idle -> "Проверить сейчас"
+ is UpdateState.Checking -> "Проверка…"
+ is UpdateState.UpToDate -> "Установлена последняя версия"
+ is UpdateState.Available -> "Доступна версия ${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))
+}
diff --git a/app/src/main/java/agzam/vyatsu/ui/settings/UpdateViewModel.kt b/app/src/main/java/agzam/vyatsu/ui/settings/UpdateViewModel.kt
new file mode 100644
index 0000000..266ed65
--- /dev/null
+++ b/app/src/main/java/agzam/vyatsu/ui/settings/UpdateViewModel.kt
@@ -0,0 +1,43 @@
+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 = updater.state
+
+ private val _autoEnabled = MutableStateFlow(true)
+ val autoEnabled: StateFlow = _autoEnabled.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ _autoEnabled.value = preferencesRepository.autoUpdateEnabled()
+ }
+ }
+
+ fun setAutoEnabled(enabled: Boolean) {
+ _autoEnabled.value = enabled
+ updater.setAutoEnabled(enabled)
+ }
+
+ fun checkNow() = updater.checkForUpdate()
+
+ fun downloadAndInstall() = updater.downloadAndInstall()
+
+ fun installDownloadedApk() = updater.installDownloadedApk()
+
+ fun canRequestInstalls() = updater.canRequestInstalls()
+
+ fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
+}
diff --git a/app/src/main/java/agzam/vyatsu/update/AppUpdater.kt b/app/src/main/java/agzam/vyatsu/update/AppUpdater.kt
new file mode 100644
index 0000000..3dae813
--- /dev/null
+++ b/app/src/main/java/agzam/vyatsu/update/AppUpdater.kt
@@ -0,0 +1,209 @@
+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 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.Idle)
+ val state: StateFlow = _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 {
+ runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
+ }
+ }
+
+ fun installDownloadedApk() {
+ val apk = downloadedApk ?: return
+ install(apk)
+ }
+
+ fun setAutoEnabled(enabled: Boolean) {
+ 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()) {
+ openUnknownSourcesSettings()
+ 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) {
+ 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
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 07d5da9..ca3826a 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,74 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 56%
rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml
rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 6f3b755..82cd4a2 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,6 @@
-
-
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
similarity index 56%
rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 6f3b755..82cd4a2 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,6 @@
-
-
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
index c209e78..8b857ca 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..a40ce94
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index b2dfe3d..f670ce6 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
index 4f0f1d6..c5e3338 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..3863487
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 62b611d..1d0640a 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
index 948a307..6edd421 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..f183fe0
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index 1b9a695..81e6761 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 28d4b77..42de01f 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..8adee0c
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index 9287f50..9ef9e32 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index aa7d642..7295956 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..4398489
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index 9126ae3..c2334f3 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..4438e79
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+