Compare commits

..
Author SHA1 Message Date
Agzamus 96161d6844 Merge branch 'database-refactor' 2026-09-03 20:56:20 +03:00
43 changed files with 313 additions and 949 deletions
+2 -14
View File
@@ -29,19 +29,10 @@ jobs:
- name: Set up JDK 17
# Ставим JDK вручную и явно прописываем JAVA_HOME.
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
# JDK качаем из архива Adoptium напрямую: образ раннера — Debian bullseye,
# его репозитории уже в архиве, apt-установка openjdk падает с 404.
run: |
set -euxo pipefail
apt-get update
apt-get install -y --no-install-recommends wget unzip
mkdir -p /opt/jdk
wget -qO /tmp/jdk17.tar.gz \
"https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
tar -xzf /tmp/jdk17.tar.gz -C /opt/jdk --strip-components=1
echo "JAVA_HOME=/opt/jdk" >> "$GITHUB_ENV"
echo "/opt/jdk/bin" >> "$GITHUB_PATH"
/opt/jdk/bin/java -version
apt-get install -y openjdk-17-jdk-headless unzip wget
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Install Android SDK
# Устанавливаем command-line tools и нужные компоненты SDK,
@@ -94,9 +85,6 @@ 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 }}
+1 -5
View File
@@ -1,6 +1,2 @@
# vyatsu-schedulegit
# vyatsu-schedule
## settings-branch
blank settings at first, soon will add update feature.
settings will be presetned as fourth button on the bottom of the screen.
+1 -3
View File
@@ -17,9 +17,7 @@ android {
minSdk = 28
targetSdk = 36
versionCode = 1
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0"
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
-11
View File
@@ -3,7 +3,6 @@
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"
@@ -26,16 +25,6 @@
<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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

@@ -5,7 +5,6 @@ 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
@@ -21,7 +20,6 @@ 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()
@@ -34,7 +32,6 @@ fun AppRoot() {
}
HomeScreen(
scheduleViewModel = scheduleViewModel,
updateViewModel = updateViewModel,
onSwitchGroup = { scheduleViewModel.switchGroup() }
)
}
@@ -46,4 +43,3 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
@@ -10,8 +10,6 @@ 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,10 +27,4 @@ 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()
val tableSvg = StringBuilder()
private 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" style='font-size: 50%;'>
<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">
<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) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
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")
}
columns.add(TableColumn(left, right-left, cells))
}
@@ -2,7 +2,6 @@ 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
@@ -26,10 +25,6 @@ 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 {
@@ -40,8 +35,6 @@ object PdfScheduleParser {
}
val days = mutableListOf<Day>()
var group: String? = null
try {
for (pageIndex in 0 until doc.numberOfPages) {
Log.v(TAG, "============ Page $pageIndex ============")
@@ -52,6 +45,12 @@ 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")
// Парсинг текста
@@ -61,11 +60,6 @@ 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] // Колонка с инофрмацией о паре
@@ -106,17 +100,10 @@ object PdfScheduleParser {
}
}
for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ')
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: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
}
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
val date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>()
@@ -137,55 +124,7 @@ object PdfScheduleParser {
val content = content(lessonCell)
Log.v(TAG, "- $content")
// TODO: парсинг контента лекции
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
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))
lessons.add(Lesson(raw=content))
}
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
}
@@ -194,18 +133,6 @@ 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,7 +7,6 @@ import com.tom_roush.pdfbox.text.TextPosition
* Парсер блоков текста из PDF
* @author Agzam
*/
class PdfTextParser : PDFTextStripper() {
data class TextBlock(
@@ -26,32 +25,9 @@ class PdfTextParser : PDFTextStripper() {
}
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
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
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
val first = textPositions.first()
val last = textPositions.last()
val matrix = first.textMatrix
val rotation = first.dir
@@ -60,17 +36,10 @@ class PdfTextParser : PDFTextStripper() {
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
blocks.add(
TextBlock(
text = wordText,
x = x,
y = currentPage.mediaBox.height - y - height,
width = totalWidth,
height = height,
rotation = rotation
)
)
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
}
}
}
@@ -1,56 +0,0 @@
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
}
@@ -23,6 +23,9 @@ class GroupRepositoryImpl(
override suspend fun getGroups(): List<Group> =
groupDao.observeAll().first().map { it.toDomain() }
override suspend fun getGroup(id: String): Group? =
groupDao.getById(id)?.toDomain()
override suspend fun getFaculties(): List<String> = groupDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
@@ -13,7 +13,4 @@ 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,7 +157,6 @@ 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,7 +9,6 @@ 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
@@ -38,8 +37,4 @@ class AppContainer(context: Context) {
val preferencesRepository: PreferencesRepository by lazy {
PreferencesRepositoryImpl(database.preferencesDao())
}
val appUpdater: AppUpdater by lazy {
AppUpdater(context.applicationContext, preferencesRepository)
}
}
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
interface GroupRepository {
fun observeGroups(): Flow<List<Group>>
suspend fun getGroups(): List<Group>
suspend fun getGroup(id: String): Group?
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
@@ -8,7 +8,4 @@ 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,15 +4,12 @@ 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
@@ -28,14 +25,12 @@ import androidx.compose.ui.Modifier
private enum class HomeTab(val label: String) {
SCHEDULE("Расписание"),
CALLS("Звонки"),
BUILDINGS("Корпуса"),
SETTINGS("Настройки")
BUILDINGS("Корпуса")
}
@Composable
fun HomeScreen(
scheduleViewModel: ScheduleViewModel,
updateViewModel: UpdateViewModel,
onSwitchGroup: () -> Unit
) {
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
@@ -53,7 +48,6 @@ 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
)
@@ -73,7 +67,6 @@ fun HomeScreen(
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
HomeTab.CALLS -> CallsScreen()
HomeTab.BUILDINGS -> BuildingsScreen()
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
}
}
}
@@ -1,12 +1,9 @@
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
@@ -15,18 +12,14 @@ 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
@@ -40,12 +33,8 @@ 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
@@ -109,23 +98,31 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
style = MaterialTheme.typography.titleMedium
)
WeekSelector(
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
Text("Очистить эту неделю")
}
TextButton(onClick = { viewModel.clearAll() }) {
Text("Очистить всё")
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
WeekChips(
periods = schedule.periods,
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
onSelect = viewModel::selectWeek
)
}
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
HorizontalDivider(Modifier.padding(vertical = 8.dp))
DayChips(
days = weekDays,
@@ -134,7 +131,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
onSelect = viewModel::selectDay
)
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
HorizontalDivider(Modifier.padding(vertical = 8.dp))
if (day.intervals.all { it.lessons.isEmpty() }) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
@@ -155,70 +152,42 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WeekSelector(
private fun WeekChips(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
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 ->
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
val selected = selectedWeekStart == period.weekStart
val isTodayWeek = todayWeekStart == period.weekStart
DropdownMenuItem(
text = {
FilterChip(
selected = selected,
onClick = { onSelect(period.weekStart) },
label = {
Text(
weekLabel(period),
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.labelLarge
)
},
trailingIcon = if (isTodayWeek) {
{
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayWeek && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
}
} else {
null
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
onClick = {
expanded = false
onSelect(period.weekStart)
}
shape = RoundedCornerShape(8.dp)
)
}
}
}
}
@Composable
private fun DayChips(
@@ -286,48 +255,22 @@ 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.type.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if(subject.isNotEmpty()) {
Text(
subject,
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
style = MaterialTheme.typography.bodyLarge
)
any = true
}
val meta = listOfNotNull(
Text(
listOfNotNull(
lesson.type.label,
lesson.subgroup,
lesson.teacher,
lesson.room
).filter { it.isNotBlank() }.joinToString(" · ")
if(meta.isNotEmpty()) {
Text(
meta,
)
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
if(!any) {
Text(
"-",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
@@ -11,7 +12,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository,
@@ -30,7 +30,7 @@ class ScheduleViewModel(
if (groupId != null) {
selectGroup(groupId)
} else {
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
_uiState.value = _uiState.value.copy(noGroup = true)
}
}
}
@@ -39,104 +39,32 @@ 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)
}.getOrNull()
val savedDayDate = runCatching {
preferencesRepository.selectedDayDate()?.let(LocalDate::parse)
}.getOrNull()
_uiState.value = _uiState.value.copy(
noGroup = false,
groupName = groupName,
weekStart = savedWeekStart,
dayDate = savedDayDate
)
val groupName = groupRepository.getGroup(groupId)?.name
_uiState.value = _uiState.value.copy(noGroup = false, groupName = groupName)
launch {
scheduleRepository.observeSchedule(groupId).collect { schedule ->
currentGroupId?.let { applySchedule(schedule) }
currentGroupId?.let {
val days = schedule?.days.orEmpty()
val today = todayIndex(days)
val current = _uiState.value
val selected = current.selectedIndex.takeIf { i -> i in days.indices } ?: today
_uiState.value = current.copy(
schedule = schedule,
todayIndex = today,
selectedIndex = selected
)
}
}
}
launch {
syncIfMissingToday(groupId)
}
}
}
private suspend fun applySchedule(schedule: Schedule?) {
val current = _uiState.value
if (schedule == null || schedule.periods.isEmpty()) {
_uiState.value = current.copy(schedule = schedule, todayDate = null)
return
}
val today = ScheduleDates.moscowToday()
val todayDate = if (schedule.periodOf(today) != null) today else null
val weekStart = when {
schedule.periods.any { it.weekStart == current.weekStart } -> current.weekStart!!
todayDate != null -> schedule.periodOf(todayDate)!!.weekStart
else -> schedule.periods.first().weekStart
}
val period = schedule.periods.first { it.weekStart == weekStart }
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == current.dayDate } -> current.dayDate
weekDays.any { it.date == todayDate } -> todayDate
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = current.copy(
schedule = schedule,
weekStart = weekStart,
dayDate = dayDate,
todayDate = todayDate
)
}
fun selectWeek(weekStart: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch {
val today = ScheduleDates.moscowToday()
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == today } -> today
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = _uiState.value.copy(weekStart = weekStart, dayDate = dayDate)
}
}
fun selectDay(date: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periodOf(date) ?: return
viewModelScope.launch {
preferencesRepository.setSelectedWeekStart(period.weekStart.toString())
preferencesRepository.setSelectedDayDate(date.toString())
_uiState.value = _uiState.value.copy(weekStart = period.weekStart, dayDate = date)
}
}
private suspend fun syncIfMissingToday(groupId: String) {
val schedule = scheduleRepository.getSchedule(groupId)
val today = ScheduleDates.moscowToday()
val hasTodayPeriod = schedule?.periodOf(today) != null
if (hasTodayPeriod) return
_uiState.value = _uiState.value.copy(isSyncing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isSyncing = false)
}
}
}
fun switchGroup() {
viewModelScope.launch {
@@ -151,10 +79,23 @@ class ScheduleViewModel(
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isRefreshing = false)
val current = _uiState.value
val days = current.schedule?.days.orEmpty()
val today = todayIndex(days)
_uiState.value = current.copy(
isRefreshing = false,
todayIndex = today,
selectedIndex = current.selectedIndex.takeIf { it in days.indices } ?: today
)
}
}
fun selectDay(index: Int) {
val days = _uiState.value.schedule?.days.orEmpty()
if (index !in days.indices) return
_uiState.value = _uiState.value.copy(selectedIndex = index)
}
fun clearAll() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
@@ -165,21 +106,29 @@ class ScheduleViewModel(
fun clearSelectedPeriod() {
val state = _uiState.value
val schedule = state.schedule ?: return
val weekStart = state.weekStart ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
val day = schedule.days.getOrNull(state.selectedIndex) ?: return
val period = schedule.periodOf(day.date) ?: return
viewModelScope.launch {
scheduleRepository.clearPeriod(schedule.groupId, period)
}
}
private fun todayIndex(days: List<Day>): Int {
if (days.isEmpty()) return 0
val today = ScheduleDates.moscowToday()
val exact = days.indexOfFirst { it.date == today }
if (exact >= 0) return exact
val next = days.indexOfFirst { !it.date.isBefore(today) }
return if (next >= 0) next else days.lastIndex
}
}
data class ScheduleUiState(
val noGroup: Boolean = false,
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
val schedule: Schedule? = null,
val todayIndex: Int = 0,
val selectedIndex: Int = 0
)
@@ -1,150 +0,0 @@
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))
}
@@ -1,43 +0,0 @@
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()
}
@@ -1,209 +0,0 @@
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,74 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:viewportHeight="108"
android:height="108dp"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<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"/>
<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" />
</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="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/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="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="updates"
path="updates/" />
</paths>