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 @@ + + + +