Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27976ace5 | ||
|
|
4e180c8b9b | ||
|
|
1195e458a0 | ||
|
|
54d20bd6e0 |
@@ -1,2 +1,6 @@
|
|||||||
# vyatsu-schedule
|
# vyatsu-schedulegit
|
||||||
|
|
||||||
|
## settings-branch
|
||||||
|
blank settings at first, soon will add update feature.
|
||||||
|
|
||||||
|
settings will be presetned as fourth button on the bottom of the screen.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".VyatsuApplication"
|
android:name=".VyatsuApplication"
|
||||||
@@ -25,6 +26,16 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 234 KiB |
@@ -5,6 +5,7 @@ import agzam.vyatsu.ui.groups.GroupPickerScreen
|
|||||||
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||||
import agzam.vyatsu.ui.home.HomeScreen
|
import agzam.vyatsu.ui.home.HomeScreen
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
|
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -20,6 +21,7 @@ fun AppRoot() {
|
|||||||
|
|
||||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||||
|
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||||
|
|
||||||
val state by scheduleViewModel.uiState.collectAsState()
|
val state by scheduleViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ fun AppRoot() {
|
|||||||
}
|
}
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
scheduleViewModel = scheduleViewModel,
|
scheduleViewModel = scheduleViewModel,
|
||||||
|
updateViewModel = updateViewModel,
|
||||||
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -43,3 +46,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 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 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
val container = (application as VyatsuApplication).container
|
||||||
|
container.appUpdater.autoCheckAndInstall()
|
||||||
setContent {
|
setContent {
|
||||||
VyatsuScheduleTheme {
|
VyatsuScheduleTheme {
|
||||||
AppRoot()
|
AppRoot()
|
||||||
|
|||||||
@@ -27,4 +27,10 @@ interface PreferencesDao {
|
|||||||
|
|
||||||
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
||||||
suspend fun setSelectedDayDate(date: String)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@ class PreferencesRepositoryImpl(
|
|||||||
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
||||||
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
||||||
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
|||||||
import agzam.vyatsu.domain.repository.GroupRepository
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
|
import agzam.vyatsu.update.AppUpdater
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
|
||||||
@@ -37,4 +38,8 @@ class AppContainer(context: Context) {
|
|||||||
val preferencesRepository: PreferencesRepository by lazy {
|
val preferencesRepository: PreferencesRepository by lazy {
|
||||||
PreferencesRepositoryImpl(database.preferencesDao())
|
PreferencesRepositoryImpl(database.preferencesDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val appUpdater: AppUpdater by lazy {
|
||||||
|
AppUpdater(context.applicationContext, preferencesRepository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ interface PreferencesRepository {
|
|||||||
suspend fun setSelectedWeekStart(weekStart: String)
|
suspend fun setSelectedWeekStart(weekStart: String)
|
||||||
suspend fun selectedDayDate(): String?
|
suspend fun selectedDayDate(): String?
|
||||||
suspend fun setSelectedDayDate(date: String)
|
suspend fun setSelectedDayDate(date: String)
|
||||||
|
|
||||||
|
suspend fun autoUpdateEnabled(): Boolean
|
||||||
|
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import agzam.vyatsu.ui.info.BuildingsScreen
|
|||||||
import agzam.vyatsu.ui.info.CallsScreen
|
import agzam.vyatsu.ui.info.CallsScreen
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
@@ -25,12 +28,14 @@ import androidx.compose.ui.Modifier
|
|||||||
private enum class HomeTab(val label: String) {
|
private enum class HomeTab(val label: String) {
|
||||||
SCHEDULE("Расписание"),
|
SCHEDULE("Расписание"),
|
||||||
CALLS("Звонки"),
|
CALLS("Звонки"),
|
||||||
BUILDINGS("Корпуса")
|
BUILDINGS("Корпуса"),
|
||||||
|
SETTINGS("Настройки")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
scheduleViewModel: ScheduleViewModel,
|
scheduleViewModel: ScheduleViewModel,
|
||||||
|
updateViewModel: UpdateViewModel,
|
||||||
onSwitchGroup: () -> Unit
|
onSwitchGroup: () -> Unit
|
||||||
) {
|
) {
|
||||||
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||||
@@ -48,6 +53,7 @@ fun HomeScreen(
|
|||||||
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||||
HomeTab.CALLS -> Icons.Default.Info
|
HomeTab.CALLS -> Icons.Default.Info
|
||||||
HomeTab.BUILDINGS -> Icons.Default.Home
|
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||||
|
HomeTab.SETTINGS -> Icons.Default.Settings
|
||||||
},
|
},
|
||||||
contentDescription = t.label
|
contentDescription = t.label
|
||||||
)
|
)
|
||||||
@@ -67,6 +73,7 @@ fun HomeScreen(
|
|||||||
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||||
HomeTab.CALLS -> CallsScreen()
|
HomeTab.CALLS -> CallsScreen()
|
||||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||||
|
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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<UpdateState> = updater.state
|
||||||
|
|
||||||
|
private val _autoEnabled = MutableStateFlow(true)
|
||||||
|
val autoEnabled: StateFlow<Boolean> = _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()
|
||||||
|
}
|
||||||
@@ -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>(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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,170 +1,74 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path
|
<path android:fillColor="#3DDC84"
|
||||||
android:fillColor="#3DDC84"
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||||
android:pathData="M9,0L9,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M19,0L19,108"
|
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||||
android:pathData="M29,0L29,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M39,0L39,108"
|
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||||
android:pathData="M49,0L49,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M59,0L59,108"
|
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||||
android:pathData="M69,0L69,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M79,0L79,108"
|
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||||
android:pathData="M89,0L89,108"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M99,0L99,108"
|
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||||
android:pathData="M0,9L108,9"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
<path
|
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:pathData="M0,19L108,19"
|
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeColor="#33FFFFFF" />
|
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||||
<path
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:fillColor="#00000000"
|
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||||
android:pathData="M0,29L108,29"
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path
|
||||||
|
name="updates"
|
||||||
|
path="updates/" />
|
||||||
|
</paths>
|
||||||