fix: ssl error & offline teacher schedule
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@drawable/icon"
|
android:icon="@drawable/icon"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.VyatsuSchedule">
|
android:theme="@style/Theme.VyatsuSchedule">
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ interface TeacherScheduleDao {
|
|||||||
@Query("DELETE FROM teacher_schedules")
|
@Query("DELETE FROM teacher_schedules")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
@Query("SELECT lastSyncedAt FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart")
|
||||||
|
suspend fun lastSyncedAt(departmentId: String, season: String, weekStart: Long): Long?
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertDays(days: List<TeacherDayEntity>): List<Long>
|
suspend fun insertDays(days: List<TeacherDayEntity>): List<Long>
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ interface TeacherScheduleDao {
|
|||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun replaceSchedule(schedule: TeacherScheduleEntity, days: List<TeacherDayEntity>, lessons: List<TeacherLessonEntity>) {
|
suspend fun replaceSchedule(schedule: TeacherScheduleEntity, days: List<TeacherDayEntity>, lessons: List<TeacherLessonEntity>) {
|
||||||
|
deleteSchedule(schedule.departmentId, schedule.season.name, schedule.weekStart)
|
||||||
val scheduleId = insertSchedule(schedule)
|
val scheduleId = insertSchedule(schedule)
|
||||||
val daysWithScheduleId = days.map { it.copy(scheduleId = scheduleId) }
|
val daysWithScheduleId = days.map { it.copy(scheduleId = scheduleId) }
|
||||||
deleteDaysForSchedule(scheduleId)
|
deleteDaysForSchedule(scheduleId)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TeacherScheduleRepositoryImpl(
|
|||||||
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
|
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
|
||||||
.map { names -> names.map { name -> Teacher(name = name) } }
|
.map { names -> names.map { name -> Teacher(name = name) } }
|
||||||
|
|
||||||
override suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> = flow {
|
override suspend fun syncPeriod(date: LocalDate, force: Boolean): Flow<BulkSyncProgress> = flow {
|
||||||
// Unique index (departmentId, season, weekStart) breaks on interleaved
|
// Unique index (departmentId, season, weekStart) breaks on interleaved
|
||||||
// delete+insert from concurrent syncPeriod() callers.
|
// delete+insert from concurrent syncPeriod() callers.
|
||||||
syncMutex.withLock {
|
syncMutex.withLock {
|
||||||
@@ -68,18 +68,27 @@ class TeacherScheduleRepositoryImpl(
|
|||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
targets.map { it.season to it.weekStart }
|
val now = now()
|
||||||
.distinct()
|
var skippedCount = 0
|
||||||
.forEach { (season, weekStart) ->
|
val nonSkipped = mutableListOf<DepartmentPeriodEntity>()
|
||||||
withContext(Dispatchers.IO) {
|
for (target in targets) {
|
||||||
scheduleDao.deletePeriod(season.name, weekStart)
|
val last = withContext(Dispatchers.IO) {
|
||||||
}
|
scheduleDao.lastSyncedAt(target.departmentId, target.season.name, target.weekStart)
|
||||||
}
|
}
|
||||||
|
val shouldSkip = !force && last != null && now - last < STALE_WINDOW_MS
|
||||||
|
if (shouldSkip) {
|
||||||
|
skippedCount++
|
||||||
|
} else {
|
||||||
|
nonSkipped.add(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(BulkSyncProgress(total = targets.size, done = skippedCount, failed = 0))
|
||||||
|
|
||||||
val total = targets.size
|
val total = targets.size
|
||||||
val semaphore = Semaphore(PARALLELISM)
|
val semaphore = Semaphore(PARALLELISM)
|
||||||
val jobs = coroutineScope {
|
val jobs = coroutineScope {
|
||||||
targets.map { target ->
|
nonSkipped.map { target ->
|
||||||
async {
|
async {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
try {
|
try {
|
||||||
@@ -102,7 +111,7 @@ class TeacherScheduleRepositoryImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var done = 0
|
var done = skippedCount
|
||||||
var failed = 0
|
var failed = 0
|
||||||
for (job in jobs) {
|
for (job in jobs) {
|
||||||
if (job.await()) done++ else failed++
|
if (job.await()) done++ else failed++
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import java.time.LocalDate
|
|||||||
interface TeacherScheduleRepository {
|
interface TeacherScheduleRepository {
|
||||||
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
|
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
|
||||||
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
|
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
|
||||||
suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress>
|
suspend fun syncPeriod(date: LocalDate, force: Boolean = false): Flow<BulkSyncProgress>
|
||||||
suspend fun hasData(period: SchedulePeriod): Boolean
|
suspend fun hasData(period: SchedulePeriod): Boolean
|
||||||
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
|
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
|
||||||
suspend fun clearAll()
|
suspend fun clearAll()
|
||||||
|
|||||||
@@ -146,7 +146,15 @@ class PickerViewModel(
|
|||||||
syncJob = viewModelScope.launch {
|
syncJob = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
teacherScheduleRepository.syncPeriod(today).collect { progress ->
|
teacherScheduleRepository.syncPeriod(today).collect { progress ->
|
||||||
_uiState.value = _uiState.value.copy(syncProgress = progress)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
syncProgress = progress,
|
||||||
|
error = if (progress.failed > 0) {
|
||||||
|
"Не удалось загрузить расписание: ${progress.failed} из " +
|
||||||
|
"${progress.total} кафедр. Проверьте подключение к интернету."
|
||||||
|
} else {
|
||||||
|
_uiState.value.error
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ class TeacherScheduleViewModel(
|
|||||||
if (!scheduleRepository.hasData(period)) {
|
if (!scheduleRepository.hasData(period)) {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||||
try {
|
try {
|
||||||
scheduleRepository.syncPeriod(period.weekStart).collect { }
|
// force = true: hasData() already proved there is nothing cached for
|
||||||
|
// this period, so never let the staleness window skip the fetch.
|
||||||
|
scheduleRepository.syncPeriod(period.weekStart, force = true).collect { }
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -199,7 +201,7 @@ class TeacherScheduleViewModel(
|
|||||||
try {
|
try {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||||
try {
|
try {
|
||||||
scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { }
|
scheduleRepository.syncPeriod(ScheduleDates.moscowToday(), force = true).collect { }
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
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
|
||||||
|
QEUxeCp6
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Сертификат vyatsu.ru перевыпущен 05.10.2026 и теперь выдан в иерархии
|
||||||
|
GlobalSign Root R46, которая в системный trust store Android попала
|
||||||
|
только в Android 14. На устройствах с Android 13 и ниже все HTTPS-запросы
|
||||||
|
к сайту падают с "Trust anchor for certification path not found".
|
||||||
|
|
||||||
|
Добавляем этот корень как дополнительный якорь доверия ТОЛЬКО для доменов
|
||||||
|
vyatsu.ru — системные корни сохраняются, остальные сайты не затронуты.
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
<cleartextTrafficPermitted>false</cleartextTrafficPermitted>
|
||||||
|
</base-config>
|
||||||
|
<domain-config>
|
||||||
|
<domain includeSubdomains="true">vyatsu.ru</domain>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="@raw/globalsign_root_r46" />
|
||||||
|
</trust-anchors>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
Reference in New Issue
Block a user