Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8af24d06e3 | ||
|
|
4e180c8b9b | ||
|
|
014896aaea | ||
|
|
9f6cf3ef30 | ||
|
|
d1923d3296 | ||
|
|
686976347e | ||
|
|
699bcd81f5 | ||
|
|
050f835a33 | ||
|
|
5fee57d93a | ||
|
|
36711d1bf3 | ||
|
|
c2a9cfb7e7 | ||
|
|
467c1a735f | ||
|
|
28965e6aed | ||
|
|
181f8b2f2a | ||
|
|
84ae97b906 | ||
|
|
2069ec811a |
@@ -85,6 +85,9 @@ jobs:
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
env:
|
||||
# Версия приложения — дата, сгенерированная на предыдущем шаге.
|
||||
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
|
||||
APP_VERSION: ${{ steps.version.outputs.version_name }}
|
||||
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
||||
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -112,9 +115,3 @@ jobs:
|
||||
files: ${{ steps.apk.outputs.apk_path }}
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
server_url: ${{ gitea.server_url }}
|
||||
|
||||
- name: Upload APK as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vyatsu-schedule-${{ steps.version.outputs.version_name }}
|
||||
path: ${{ steps.apk.outputs.apk_path }}
|
||||
|
||||
@@ -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.
|
||||
@@ -17,7 +17,9 @@ android {
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||
// Локально и при отсутствии переменной — fallback "1.0".
|
||||
versionName = System.getenv("APP_VERSION") ?: "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".VyatsuApplication"
|
||||
@@ -25,6 +26,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
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.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() }
|
||||
)
|
||||
}
|
||||
@@ -41,5 +44,6 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
||||
}
|
||||
|
||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository) }
|
||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
|
||||
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
||||
|
||||
@@ -10,6 +10,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val container = (application as VyatsuApplication).container
|
||||
container.appUpdater.autoCheckAndInstall()
|
||||
setContent {
|
||||
VyatsuScheduleTheme {
|
||||
AppRoot()
|
||||
|
||||
@@ -27,4 +27,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)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
|
||||
private val svgBuilder = StringBuilder()
|
||||
private val currentPath = StringBuilder()
|
||||
private val tableSvg = StringBuilder()
|
||||
val tableSvg = StringBuilder()
|
||||
|
||||
|
||||
private val currentSubPathLines = mutableListOf<PdfLine>()
|
||||
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
val width = mediaBox.width
|
||||
val height = mediaBox.height
|
||||
return """
|
||||
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
|
||||
<g>${svgBuilder}</g>
|
||||
<g>${tableSvg}</g>
|
||||
</svg>
|
||||
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
val bottom = ys[ii+1]
|
||||
cells.add(TableCell(left, top, right-left, bottom-top))
|
||||
|
||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||
}
|
||||
columns.add(TableColumn(left, right-left, cells))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.data.remote.pdf
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import android.util.Log
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
@@ -25,6 +26,10 @@ object PdfScheduleParser {
|
||||
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
||||
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
||||
|
||||
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
|
||||
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||
val doc = try {
|
||||
@@ -35,6 +40,8 @@ object PdfScheduleParser {
|
||||
}
|
||||
|
||||
val days = mutableListOf<Day>()
|
||||
var group: String? = null
|
||||
|
||||
try {
|
||||
for (pageIndex in 0 until doc.numberOfPages) {
|
||||
Log.v(TAG, "============ Page $pageIndex ============")
|
||||
@@ -45,12 +52,6 @@ object PdfScheduleParser {
|
||||
svg.processPage(page)
|
||||
val cols = svg.asTable()
|
||||
|
||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||
Log.v(SVG_TAG, "====")
|
||||
for(line in svg.asSvg().split("\n")) {
|
||||
Log.v(SVG_TAG, line)
|
||||
}
|
||||
|
||||
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
||||
|
||||
// Парсинг текста
|
||||
@@ -60,6 +61,11 @@ object PdfScheduleParser {
|
||||
textStripper.getText(doc)
|
||||
val texts = textStripper.blocks
|
||||
|
||||
for(text in texts) {
|
||||
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
|
||||
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
|
||||
}
|
||||
|
||||
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
||||
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
||||
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
||||
@@ -100,10 +106,17 @@ object PdfScheduleParser {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (dayCell in daysColumn.cells) {
|
||||
val day = content(dayCell).split(' ')
|
||||
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||
if(day.size == 1 && day.first() == "День") {
|
||||
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
|
||||
group = content(groupCell)
|
||||
continue
|
||||
}
|
||||
if(day.size != 2) {
|
||||
Log.w(TAG, "Day data is not in format <day> <date>: $day")
|
||||
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||
}
|
||||
|
||||
val date = dateFrom(day[1]) ?: continue
|
||||
val intervals = mutableListOf<TimeSlot>()
|
||||
@@ -124,7 +137,55 @@ object PdfScheduleParser {
|
||||
val content = content(lessonCell)
|
||||
Log.v(TAG, "- $content")
|
||||
// TODO: парсинг контента лекции
|
||||
lessons.add(Lesson(raw=content))
|
||||
|
||||
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||
var found = 0
|
||||
var currentStart = 0
|
||||
for (roomMatch in ROOM_REGEX.findAll(content)) {
|
||||
found++
|
||||
|
||||
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||
currentStart = roomMatch.range.last + 1
|
||||
|
||||
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
|
||||
|
||||
val room = roomMatch.value
|
||||
|
||||
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||
if(typeMatch.isEmpty()) {
|
||||
lessons.add(Lesson(
|
||||
raw=raw,
|
||||
room=room,
|
||||
))
|
||||
continue
|
||||
}
|
||||
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
|
||||
val type = LessonType.fromRaw(typeMatch.first().value.trim())
|
||||
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
|
||||
var subgroup: String? = null
|
||||
Log.d(TAG, "$subject $group")
|
||||
if(group != null && subject.startsWith("$group,")) {
|
||||
subject = subject.substring(group.length + 1).trim()
|
||||
val subgroupMatch = SUBGROUP_REGEX.find(subject)
|
||||
if(subgroupMatch != null) {
|
||||
subgroup = subgroupMatch.value.trim()
|
||||
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
lessons.add(Lesson(
|
||||
raw=raw,
|
||||
subject=subject,
|
||||
room=room,
|
||||
type=type,
|
||||
teacher=teacher,
|
||||
subgroup=subgroup
|
||||
))
|
||||
}
|
||||
|
||||
if(found == 0) lessons.add(Lesson(raw=content))
|
||||
|
||||
}
|
||||
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
||||
}
|
||||
@@ -133,6 +194,18 @@ object PdfScheduleParser {
|
||||
for (text in texts.filter { it.rotation != 0f }) {
|
||||
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
||||
}
|
||||
|
||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||
Log.v(SVG_TAG, "====")
|
||||
for(line in svg.asSvg().split("\n")) {
|
||||
if(line.length > 250) {
|
||||
Log.v(SVG_TAG, "length: " + line.length)
|
||||
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
|
||||
continue
|
||||
}
|
||||
Log.v(SVG_TAG, line)
|
||||
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Parsing error", e)
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
|
||||
* Парсер блоков текста из PDF
|
||||
* @author Agzam
|
||||
*/
|
||||
|
||||
class PdfTextParser : PDFTextStripper() {
|
||||
|
||||
data class TextBlock(
|
||||
@@ -25,9 +26,32 @@ class PdfTextParser : PDFTextStripper() {
|
||||
}
|
||||
|
||||
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
||||
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
|
||||
val first = textPositions.first()
|
||||
val last = textPositions.last()
|
||||
if (textPositions.isNullOrEmpty()) return
|
||||
|
||||
val currentWordChars = mutableListOf<TextPosition>()
|
||||
|
||||
for (tp in textPositions) {
|
||||
val unicode = tp.unicode
|
||||
|
||||
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
|
||||
addWordBlock(currentWordChars)
|
||||
currentWordChars.clear()
|
||||
} else {
|
||||
currentWordChars.add(tp)
|
||||
}
|
||||
}
|
||||
|
||||
addWordBlock(currentWordChars)
|
||||
}
|
||||
|
||||
private fun addWordBlock(wordChars: List<TextPosition>) {
|
||||
if (wordChars.isEmpty()) return
|
||||
|
||||
val first = wordChars.first()
|
||||
val last = wordChars.last()
|
||||
|
||||
val wordText = wordChars.joinToString("") { it.unicode }.trim()
|
||||
if (wordText.isEmpty()) return
|
||||
|
||||
val matrix = first.textMatrix
|
||||
val rotation = first.dir
|
||||
@@ -36,10 +60,17 @@ class PdfTextParser : PDFTextStripper() {
|
||||
|
||||
val x = matrix.translateX
|
||||
val y = matrix.translateY - first.heightDir
|
||||
|
||||
val height = first.heightDir
|
||||
|
||||
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
|
||||
}
|
||||
blocks.add(
|
||||
TextBlock(
|
||||
text = wordText,
|
||||
x = x,
|
||||
y = currentPage.mediaBox.height - y - height,
|
||||
width = totalWidth,
|
||||
height = height,
|
||||
rotation = rotation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package agzam.vyatsu.data.remote.update
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GiteaRelease(
|
||||
@Json(name = "tag_name") val tagName: String?,
|
||||
val name: String?,
|
||||
val body: String?,
|
||||
val assets: List<GiteaAsset> = emptyList()
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GiteaAsset(
|
||||
val name: String?,
|
||||
@Json(name = "browser_download_url") val browserDownloadUrl: String?
|
||||
)
|
||||
|
||||
class GiteaReleaseApi(
|
||||
private val client: OkHttpClient,
|
||||
private val baseUrl: String = "https://vantablacktea.ddns.net",
|
||||
private val repoPath: String = "agzam/vyatsu-schedule"
|
||||
) {
|
||||
|
||||
private val moshi = Moshi.Builder().build()
|
||||
private val releaseAdapter = moshi.adapter(GiteaRelease::class.java)
|
||||
|
||||
/**
|
||||
* Возвращает последний релиз или null, если релизов ещё нет (404).
|
||||
*/
|
||||
suspend fun latestRelease(): GiteaRelease? {
|
||||
val url = "$baseUrl/api/v1/repos/$repoPath/releases/latest"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
|
||||
return client.newCall(request).execute().use { resp ->
|
||||
when {
|
||||
resp.code == 404 -> null
|
||||
!resp.isSuccessful -> throw IOException("HTTP ${resp.code} при запросе релиза")
|
||||
else -> {
|
||||
val body = resp.body ?: throw IOException("Пустой ответ сервера")
|
||||
releaseAdapter.fromJson(body.source())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun apkDownloadUrl(release: GiteaRelease): String? =
|
||||
release.assets
|
||||
.firstOrNull { it.name?.endsWith(".apk", ignoreCase = true) == true }
|
||||
?.browserDownloadUrl
|
||||
}
|
||||
@@ -13,4 +13,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())
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ class ScheduleRepositoryImpl(
|
||||
val lessonsByDay = lessonEntities.groupBy { it.dayId }
|
||||
val days = dayEntities
|
||||
.sortedBy { it.date }
|
||||
.distinctBy { it.date }
|
||||
.map { day ->
|
||||
val lessons = lessonsByDay[day.id].orEmpty()
|
||||
.groupBy { it.slotIndex }
|
||||
|
||||
@@ -9,6 +9,7 @@ import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import agzam.vyatsu.update.AppUpdater
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
|
||||
@@ -37,4 +38,8 @@ class AppContainer(context: Context) {
|
||||
val preferencesRepository: PreferencesRepository by lazy {
|
||||
PreferencesRepositoryImpl(database.preferencesDao())
|
||||
}
|
||||
|
||||
val appUpdater: AppUpdater by lazy {
|
||||
AppUpdater(context.applicationContext, preferencesRepository)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,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)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import agzam.vyatsu.ui.info.BuildingsScreen
|
||||
import agzam.vyatsu.ui.info.CallsScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import agzam.vyatsu.ui.settings.SettingsScreen
|
||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
@@ -25,12 +28,14 @@ import androidx.compose.ui.Modifier
|
||||
private enum class HomeTab(val label: String) {
|
||||
SCHEDULE("Расписание"),
|
||||
CALLS("Звонки"),
|
||||
BUILDINGS("Корпуса")
|
||||
BUILDINGS("Корпуса"),
|
||||
SETTINGS("Настройки")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
scheduleViewModel: ScheduleViewModel,
|
||||
updateViewModel: UpdateViewModel,
|
||||
onSwitchGroup: () -> Unit
|
||||
) {
|
||||
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||
@@ -48,6 +53,7 @@ fun HomeScreen(
|
||||
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||
HomeTab.CALLS -> Icons.Default.Info
|
||||
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||
HomeTab.SETTINGS -> Icons.Default.Settings
|
||||
},
|
||||
contentDescription = t.label
|
||||
)
|
||||
@@ -67,6 +73,7 @@ fun HomeScreen(
|
||||
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||
HomeTab.CALLS -> CallsScreen()
|
||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -12,14 +15,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
@@ -33,8 +40,12 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
@@ -47,7 +58,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Расписание") },
|
||||
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||
actions = {
|
||||
TextButton(onClick = { onSwitchGroup() }) {
|
||||
Text("Сменить группу")
|
||||
@@ -56,7 +67,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
@@ -98,31 +109,23 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
val isTodayDay = day.date == state.todayDate
|
||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||
|
||||
Text(
|
||||
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
|
||||
Text("Очистить эту неделю")
|
||||
}
|
||||
TextButton(onClick = { viewModel.clearAll() }) {
|
||||
Text("Очистить всё")
|
||||
}
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
WeekChips(
|
||||
Text(
|
||||
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
WeekSelector(
|
||||
periods = schedule.periods,
|
||||
selectedWeekStart = state.weekStart,
|
||||
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
||||
onSelect = viewModel::selectWeek
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
|
||||
|
||||
DayChips(
|
||||
days = weekDays,
|
||||
@@ -131,7 +134,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
onSelect = viewModel::selectDay
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
|
||||
|
||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
@@ -152,42 +155,70 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WeekChips(
|
||||
private fun WeekSelector(
|
||||
periods: List<SchedulePeriod>,
|
||||
selectedWeekStart: LocalDate?,
|
||||
todayWeekStart: LocalDate?,
|
||||
onSelect: (LocalDate) -> Unit
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
|
||||
val selected = selectedWeekStart == period.weekStart
|
||||
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(enabled = true, onClick = { expanded = true })
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
selected?.let(::weekLabel) ?: "Период",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.widthIn(min = 220.dp)
|
||||
) {
|
||||
periods.forEach { period ->
|
||||
val isTodayWeek = todayWeekStart == period.weekStart
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(period.weekStart) },
|
||||
label = {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
weekLabel(period),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
border = if (isTodayWeek && !selected) {
|
||||
FilterChipDefaults.filterChipBorder(
|
||||
borderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 1.dp,
|
||||
enabled = true,
|
||||
selected = false
|
||||
trailingIcon = if (isTodayWeek) {
|
||||
{
|
||||
Text(
|
||||
"сегодня",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
|
||||
null
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(period.weekStart)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayChips(
|
||||
@@ -255,22 +286,48 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
} else {
|
||||
slot.lessons.forEach { lesson ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
var any = false
|
||||
if(lesson.type != LessonType.OTHER) {
|
||||
Text(
|
||||
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
lesson.type.label,
|
||||
lesson.subgroup,
|
||||
lesson.teacher,
|
||||
lesson.room
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||
if(subject.isNotEmpty()) {
|
||||
Text(
|
||||
subject,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val meta = listOfNotNull(
|
||||
lesson.subgroup,
|
||||
lesson.teacher,
|
||||
lesson.room
|
||||
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
|
||||
if(meta.isNotEmpty()) {
|
||||
Text(
|
||||
meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
if(!any) {
|
||||
Text(
|
||||
"-",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.Schedule
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -14,7 +15,8 @@ import java.time.LocalDate
|
||||
|
||||
class ScheduleViewModel(
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val groupRepository: GroupRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||
@@ -28,7 +30,7 @@ class ScheduleViewModel(
|
||||
if (groupId != null) {
|
||||
selectGroup(groupId)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +39,7 @@ class ScheduleViewModel(
|
||||
currentGroupId = groupId
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedGroup(groupId)
|
||||
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||
|
||||
val savedWeekStart = runCatching {
|
||||
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
|
||||
@@ -46,6 +49,7 @@ class ScheduleViewModel(
|
||||
}.getOrNull()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
noGroup = false,
|
||||
groupName = groupName,
|
||||
weekStart = savedWeekStart,
|
||||
dayDate = savedDayDate
|
||||
)
|
||||
@@ -174,6 +178,7 @@ data class ScheduleUiState(
|
||||
val isRefreshing: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val schedule: Schedule? = null,
|
||||
val groupName: String? = null,
|
||||
val weekStart: LocalDate? = null,
|
||||
val dayDate: LocalDate? = null,
|
||||
val todayDate: LocalDate? = null
|
||||
|
||||
@@ -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"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
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" />
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</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>
|
||||