fix: streaming parsing to prevent crashes on low-end phones

This commit is contained in:
2026-09-11 01:34:05 +03:00
parent 65bb6cc7db
commit 1c5140d751
4 changed files with 139 additions and 35 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
@@ -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,40 +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)) {
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) // val groupId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
row.select("div.grpPeriod").map { grp -> val range = DateRange(m.groupValues[3], m.groupValues[4])
Group( ranges.getOrPut(groupId) {
id = grp.attr("data-grp_period_id").dropLast(1), hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
name = removeParenthesized(grp.text()), }.getOrPut(season) { ArrayList() }.add(range)
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 {
@@ -75,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(