Compare commits

..
Author SHA1 Message Date
Agzamus 1c5140d751 fix: streaming parsing to prevent crashes on low-end phones 2026-09-11 01:34:05 +03:00
Agzamus 65bb6cc7db fix: loading instead error screen 2026-09-11 00:18:23 +03:00
7 changed files with 182 additions and 63 deletions
+1
View File
@@ -53,6 +53,7 @@ android {
} }
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
unitTests.all { it.maxHeapSize = "16m" }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
+1 -1
View File
@@ -23,7 +23,6 @@ fun AppRoot() {
val container = app.container val container = app.container
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container)) val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container)) val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
val state by scheduleViewModel.uiState.collectAsState() val state by scheduleViewModel.uiState.collectAsState()
@@ -53,6 +52,7 @@ fun AppRoot() {
} }
if (state.noGroup) { if (state.noGroup) {
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
GroupPickerScreen( GroupPickerScreen(
viewModel = groupPickerViewModel, viewModel = groupPickerViewModel,
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) } 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.DateRange
import agzam.vyatsu.domain.model.Group import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season import agzam.vyatsu.domain.model.Season
import android.util.Log import java.io.InputStream
import org.jsoup.Jsoup
/** /**
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py * Основано на 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 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( private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ", "Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк", "Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
@@ -33,41 +39,118 @@ object GroupsParser {
) )
/** /**
* Извлекает список групп с HTML страницы * Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
*
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
*/ */
fun parseGroups(html: String): List<Group> { fun parseStream(input: InputStream): ParsedGroups {
val doc = Jsoup.parse(html) val reader = input.bufferedReader(Charsets.UTF_8)
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList() val buf = CharArray(CHUNK_SIZE)
val tables = container.select("table[style~=border: none !important]") val pending = StringBuilder(CHUNK_SIZE * 2)
val groups = ArrayList<Group>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
return tables.flatMap { table -> fun addRanges(text: String) {
table.select("> tbody > tr, > tr").flatMap { row -> for (m in groupPattern.findAll(text)) {
Log.v("ROW", row.html()); val groupId = m.groupValues[1]
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) // val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
row.select("div.grpPeriod").map { grp -> ranges.getOrPut(groupId) {
Group( hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
id = grp.attr("data-grp_period_id").dropLast(1), }.getOrPut(season) { ArrayList() }.add(range)
name = removeParenthesized(grp.text()),
faculty = faculty
)
}
} }
} }
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>>> { /** Ищет конец HTML-тега — символ '>' вне кавычек. */
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>() private fun findTagEnd(s: CharSequence, start: Int): Int {
groupPattern.findAll(html).forEach { m -> var i = start
val groupId = m.groupValues[1] var quote = 0.toChar()
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING while (i < s.length) {
val range = DateRange(m.groupValues[3], m.groupValues[4]) val c = s[i]
result if (quote != 0.toChar()) {
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) } if (c == quote) quote = 0.toChar()
.getOrPut(season) { mutableListOf() } } else {
.add(range) when (c) {
'"', '\'' -> quote = c
'>' -> return i
}
}
i++
} }
return result return -1
} }
private fun shortname(name: String): String { private fun shortname(name: String): String {
@@ -76,5 +159,10 @@ object GroupsParser {
return if (sh != null) "$sh ($stripped)" else stripped 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 kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.InputStream
import java.util.concurrent.TimeUnit 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.Group
import agzam.vyatsu.domain.model.Season import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository import agzam.vyatsu.domain.repository.GroupRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class GroupRepositoryImpl( class GroupRepositoryImpl(
private val groupDao: GroupDao, private val groupDao: GroupDao,
@@ -40,11 +42,11 @@ class GroupRepositoryImpl(
} }
private suspend fun refresh() { private suspend fun refresh() {
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE) val parsed = withContext(Dispatchers.IO) {
val groups = GroupsParser.parseGroups(html) VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
val ranges = GroupsParser.parseDateRanges(html) }
val entities = groups.map { group -> val entities = parsed.groups.map { group ->
val range = ranges[group.id] val range = parsed.dateRanges[group.id]
val autumn = range?.get(Season.AUTUMN)?.lastOrNull() val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
val spring = range?.get(Season.SPRING)?.lastOrNull() val spring = range?.get(Season.SPRING)?.lastOrNull()
GroupEntity( GroupEntity(
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -75,6 +76,12 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
TopAppBar( TopAppBar(
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) }, title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
actions = { actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
TextButton(onClick = { onSwitchGroup() }) { TextButton(onClick = { onSwitchGroup() }) {
Text("Сменить группу") Text("Сменить группу")
} }
@@ -86,16 +93,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
) )
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when { when {
state.isSyncing && state.schedule == null -> { state.schedule == null && state.loadError -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
state.schedule == null -> {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -110,6 +108,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
} }
state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
else -> { else -> {
val schedule = state.schedule!! val schedule = state.schedule!!
val weekDays = state.weekStart val weekDays = state.weekStart
@@ -37,6 +37,7 @@ class ScheduleViewModel(
fun selectGroup(groupId: String) { fun selectGroup(groupId: String) {
currentGroupId = groupId currentGroupId = groupId
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId) preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
@@ -61,7 +62,7 @@ class ScheduleViewModel(
} }
launch { launch {
syncIfMissingToday(groupId) syncIfMissing(groupId)
} }
} }
} }
@@ -96,7 +97,9 @@ class ScheduleViewModel(
schedule = schedule, schedule = schedule,
weekStart = weekStart, weekStart = weekStart,
dayDate = dayDate, dayDate = dayDate,
todayDate = todayDate todayDate = todayDate,
isLoading = false,
loadError = false
) )
} }
@@ -128,30 +131,36 @@ class ScheduleViewModel(
} }
} }
private suspend fun syncIfMissingToday(groupId: String) { private suspend fun syncIfMissing(groupId: String) {
val schedule = scheduleRepository.getSchedule(groupId) if (scheduleRepository.getSchedule(groupId) != null) {
val today = ScheduleDates.moscowToday() _uiState.value = _uiState.value.copy(isLoading = false)
val hasTodayPeriod = schedule?.periodOf(today) != null return
if (hasTodayPeriod) return }
_uiState.value = _uiState.value.copy(isSyncing = true) loadSchedule(groupId)
scheduleRepository.sync(groupId) }
_uiState.value = _uiState.value.copy(isSyncing = false)
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() { fun switchGroup() {
viewModelScope.launch { viewModelScope.launch {
currentGroupId = null currentGroupId = null
preferencesRepository.clearSelectedGroup() preferencesRepository.clearSelectedGroup()
_uiState.value = _uiState.value.copy(noGroup = true) _uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
} }
} }
fun refresh() { fun refresh() {
val groupId = currentGroupId ?: return val groupId = currentGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true) loadSchedule(groupId)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isRefreshing = false)
} }
} }
@@ -175,8 +184,8 @@ class ScheduleViewModel(
data class ScheduleUiState( data class ScheduleUiState(
val noGroup: Boolean = false, val noGroup: Boolean = false,
val isRefreshing: Boolean = false, val isLoading: Boolean = false,
val isSyncing: Boolean = false, val loadError: Boolean = false,
val schedule: Schedule? = null, val schedule: Schedule? = null,
val groupName: String? = null, val groupName: String? = null,
val weekStart: LocalDate? = null, val weekStart: LocalDate? = null,