Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5140d751 | ||
|
|
65bb6cc7db |
@@ -53,6 +53,7 @@ android {
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests.all { it.maxHeapSize = "16m" }
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
|
||||
@@ -23,7 +23,6 @@ fun AppRoot() {
|
||||
val container = app.container
|
||||
|
||||
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()
|
||||
@@ -53,6 +52,7 @@ fun AppRoot() {
|
||||
}
|
||||
|
||||
if (state.noGroup) {
|
||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||
GroupPickerScreen(
|
||||
viewModel = groupPickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||
|
||||
@@ -3,8 +3,7 @@ package agzam.vyatsu.data.remote
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import android.util.Log
|
||||
import org.jsoup.Jsoup
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||
@@ -14,6 +13,13 @@ object GroupsParser {
|
||||
|
||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||
|
||||
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||
private val parenthesized = Regex("""\(.*?\)\s?""")
|
||||
|
||||
private const val CHUNK_SIZE = 8192
|
||||
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
||||
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
||||
|
||||
private val shorthands = mapOf(
|
||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||
@@ -33,41 +39,118 @@ object GroupsParser {
|
||||
)
|
||||
|
||||
/**
|
||||
* Извлекает список групп с HTML страницы
|
||||
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
||||
* разбирает его на лету, не материализуя весь HTML в память.
|
||||
*
|
||||
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
||||
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
||||
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
||||
*/
|
||||
fun parseGroups(html: String): List<Group> {
|
||||
val doc = Jsoup.parse(html)
|
||||
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList()
|
||||
val tables = container.select("table[style~=border: none !important]")
|
||||
fun parseStream(input: InputStream): ParsedGroups {
|
||||
val reader = input.bufferedReader(Charsets.UTF_8)
|
||||
val buf = CharArray(CHUNK_SIZE)
|
||||
val pending = StringBuilder(CHUNK_SIZE * 2)
|
||||
val groups = ArrayList<Group>()
|
||||
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||
var faculty = ""
|
||||
|
||||
return tables.flatMap { table ->
|
||||
table.select("> tbody > tr, > tr").flatMap { row ->
|
||||
Log.v("ROW", row.html());
|
||||
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) //
|
||||
|
||||
row.select("div.grpPeriod").map { grp ->
|
||||
Group(
|
||||
id = grp.attr("data-grp_period_id").dropLast(1),
|
||||
name = removeParenthesized(grp.text()),
|
||||
faculty = faculty
|
||||
)
|
||||
}
|
||||
fun addRanges(text: String) {
|
||||
for (m in groupPattern.findAll(text)) {
|
||||
val groupId = m.groupValues[1]
|
||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||
ranges.getOrPut(groupId) {
|
||||
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
||||
}.getOrPut(season) { ArrayList() }.add(range)
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val n = reader.read(buf)
|
||||
if (n < 0) break
|
||||
pending.append(buf, 0, n)
|
||||
|
||||
var pos = 0
|
||||
while (pos < pending.length) {
|
||||
val divAt = pending.indexOf("<div", pos)
|
||||
val aAt = pending.indexOf("<a", pos)
|
||||
val tagStart = when {
|
||||
divAt < 0 -> aAt
|
||||
aAt < 0 -> divAt
|
||||
else -> minOf(divAt, aAt)
|
||||
}
|
||||
if (tagStart < 0) {
|
||||
// хвостовой текст без div-тегов — обычный текст страницы
|
||||
pos = pending.length
|
||||
break
|
||||
}
|
||||
if (tagStart > pos) {
|
||||
addRanges(pending.substring(pos, tagStart))
|
||||
}
|
||||
val tagEnd = findTagEnd(pending, tagStart)
|
||||
if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк
|
||||
val tagText = pending.substring(tagStart, tagEnd + 1)
|
||||
|
||||
val isDiv = tagText.startsWith("<div")
|
||||
val isFakName = isDiv && tagText.contains("class=\"fak_name\"")
|
||||
val isGrpPeriod = isDiv && tagText.contains("class=\"grpPeriod\"")
|
||||
if (isFakName || isGrpPeriod) {
|
||||
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
|
||||
if (closeDiv < 0) break // содержимое не дочитано — ждём следующий чанк
|
||||
addRanges(tagText)
|
||||
val content = pending.substring(tagEnd + 1, closeDiv)
|
||||
if (isFakName) {
|
||||
val name = content.trim()
|
||||
if (name.isNotEmpty()) faculty = shortname(name)
|
||||
} else {
|
||||
val m = grpIdPattern.find(tagText)
|
||||
if (m != null) {
|
||||
groups += Group(
|
||||
id = m.groupValues[1].dropLast(1),
|
||||
name = removeParenthesized(content),
|
||||
faculty = faculty
|
||||
)
|
||||
}
|
||||
}
|
||||
pos = closeDiv + 6
|
||||
} else {
|
||||
addRanges(tagText)
|
||||
pos = tagEnd + 1
|
||||
}
|
||||
|
||||
if (pos > MAX_SLICE) {
|
||||
pending.delete(0, pos)
|
||||
pos = 0
|
||||
}
|
||||
}
|
||||
if (pos > 0 && pos <= pending.length) {
|
||||
pending.delete(0, pos)
|
||||
}
|
||||
if (pending.length > MAX_PENDING) {
|
||||
// на странице нет настолько длинных конструкций; страховка от аномалий
|
||||
pending.delete(0, pending.length - MAX_SLICE)
|
||||
}
|
||||
}
|
||||
return ParsedGroups(groups, ranges)
|
||||
}
|
||||
|
||||
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> {
|
||||
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||
groupPattern.findAll(html).forEach { m ->
|
||||
val groupId = m.groupValues[1]
|
||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||
result
|
||||
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) }
|
||||
.getOrPut(season) { mutableListOf() }
|
||||
.add(range)
|
||||
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
|
||||
private fun findTagEnd(s: CharSequence, start: Int): Int {
|
||||
var i = start
|
||||
var quote = 0.toChar()
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (quote != 0.toChar()) {
|
||||
if (c == quote) quote = 0.toChar()
|
||||
} else {
|
||||
when (c) {
|
||||
'"', '\'' -> quote = c
|
||||
'>' -> return i
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return result
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun shortname(name: String): String {
|
||||
@@ -76,5 +159,10 @@ object GroupsParser {
|
||||
return if (sh != null) "$sh ($stripped)" else stripped
|
||||
}
|
||||
|
||||
private fun removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
|
||||
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
||||
}
|
||||
|
||||
data class ParsedGroups(
|
||||
val groups: List<Group>,
|
||||
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -35,4 +36,15 @@ object VyatsuHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает потоковое тело ответа. Вызывающий обязан закрыть
|
||||
* возвращённый [InputStream], чтобы вернуть соединение в пул.
|
||||
*/
|
||||
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
|
||||
val resp = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.byteStream()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class GroupRepositoryImpl(
|
||||
private val groupDao: GroupDao,
|
||||
@@ -40,11 +42,11 @@ class GroupRepositoryImpl(
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE)
|
||||
val groups = GroupsParser.parseGroups(html)
|
||||
val ranges = GroupsParser.parseDateRanges(html)
|
||||
val entities = groups.map { group ->
|
||||
val range = ranges[group.id]
|
||||
val parsed = withContext(Dispatchers.IO) {
|
||||
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
|
||||
}
|
||||
val entities = parsed.groups.map { group ->
|
||||
val range = parsed.dateRanges[group.id]
|
||||
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
|
||||
val spring = range?.get(Season.SPRING)?.lastOrNull()
|
||||
GroupEntity(
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -75,6 +76,12 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||
actions = {
|
||||
if (state.isLoading && state.schedule != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onSwitchGroup() }) {
|
||||
Text("Сменить группу")
|
||||
}
|
||||
@@ -86,16 +93,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
state.isSyncing && state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
state.schedule == null && state.loadError -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -110,6 +108,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
val weekDays = state.weekStart
|
||||
|
||||
@@ -37,6 +37,7 @@ class ScheduleViewModel(
|
||||
|
||||
fun selectGroup(groupId: String) {
|
||||
currentGroupId = groupId
|
||||
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedGroup(groupId)
|
||||
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||
@@ -61,7 +62,7 @@ class ScheduleViewModel(
|
||||
}
|
||||
|
||||
launch {
|
||||
syncIfMissingToday(groupId)
|
||||
syncIfMissing(groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +97,9 @@ class ScheduleViewModel(
|
||||
schedule = schedule,
|
||||
weekStart = weekStart,
|
||||
dayDate = dayDate,
|
||||
todayDate = todayDate
|
||||
todayDate = todayDate,
|
||||
isLoading = false,
|
||||
loadError = false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,30 +131,36 @@ class ScheduleViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private suspend fun syncIfMissing(groupId: String) {
|
||||
if (scheduleRepository.getSchedule(groupId) != null) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
return
|
||||
}
|
||||
loadSchedule(groupId)
|
||||
}
|
||||
|
||||
private suspend fun loadSchedule(groupId: String) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||
val schedule = scheduleRepository.sync(groupId)
|
||||
_uiState.value = if (schedule != null) {
|
||||
_uiState.value.copy(isLoading = false, loadError = false)
|
||||
} else {
|
||||
_uiState.value.copy(isLoading = false, loadError = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchGroup() {
|
||||
viewModelScope.launch {
|
||||
currentGroupId = null
|
||||
preferencesRepository.clearSelectedGroup()
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val groupId = currentGroupId ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = false)
|
||||
loadSchedule(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +184,8 @@ class ScheduleViewModel(
|
||||
|
||||
data class ScheduleUiState(
|
||||
val noGroup: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val loadError: Boolean = false,
|
||||
val schedule: Schedule? = null,
|
||||
val groupName: String? = null,
|
||||
val weekStart: LocalDate? = null,
|
||||
|
||||
Reference in New Issue
Block a user