From 1d55e84e96682ee9aca8c7b538c30bc3fb9886e3 Mon Sep 17 00:00:00 2001 From: Agzamus Date: Sat, 19 Sep 2026 23:32:09 +0300 Subject: [PATCH] fix(teacher): register migration, containment period queries, concurrency guards - Register AppDatabase.MIGRATION_3_4 so v3 databases upgrade without data loss - Query teacher schedule periods by containment (weekStart<=x AND weekEnd>=x) instead of exact weekStart, matching 2-week catalog windows - Clip buildTeacherSchedule days to the requested week range - Add weeksFor() to enumerate weeks of a catalog window for the UI selector - Serialize syncPeriod body with a Mutex to protect the unique index - Rethrow CancellationException before generic catch; version loadRevision checks so stale observers/loads are cancelled - Sync job tracking in PickerViewModel; keep loading true while empty - Drop dead selectTeacher helper --- .../vyatsu/data/local/TeacherScheduleDao.kt | 12 +- .../TeacherScheduleRepositoryImpl.kt | 125 +++++++++++------ .../main/java/agzam/vyatsu/di/AppContainer.kt | 1 + .../repository/TeacherScheduleRepository.kt | 1 + .../agzam/vyatsu/ui/picker/PickerViewModel.kt | 25 ++-- .../ui/teacher/TeacherScheduleScreen.kt | 14 +- .../ui/teacher/TeacherScheduleViewModel.kt | 130 ++++++++++++------ 7 files changed, 205 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/agzam/vyatsu/data/local/TeacherScheduleDao.kt b/app/src/main/java/agzam/vyatsu/data/local/TeacherScheduleDao.kt index e995c8d..0470cfb 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/TeacherScheduleDao.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/TeacherScheduleDao.kt @@ -40,14 +40,16 @@ interface TeacherScheduleDao { @Query( "SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " + - "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart = :weekStart ORDER BY teacher_days.date" + "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " + + "AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher_days.date" ) fun observeDaysForPeriod(season: String, weekStart: Long): Flow> @Query( "SELECT teacher_lessons.* FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " + "INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " + - "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart = :weekStart " + + "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " + + "AND teacher_schedules.weekEnd >= :weekStart " + "ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex" ) fun observeLessonsForPeriod(season: String, weekStart: Long): Flow> @@ -55,12 +57,14 @@ interface TeacherScheduleDao { @Query( "SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " + "INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " + - "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart = :weekStart ORDER BY teacher" + "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " + + "AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher" ) fun observeDistinctTeachers(season: String, weekStart: Long): Flow> @Query( - "SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season AND weekStart = :weekStart)" + "SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " + + "AND weekStart <= :weekStart AND weekEnd >= :weekStart)" ) suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean } diff --git a/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt index e053aa0..88803dd 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt @@ -26,17 +26,24 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalTime +import java.time.temporal.TemporalAdjusters +import kotlin.coroutines.cancellation.CancellationException class TeacherScheduleRepositoryImpl( private val scheduleDao: TeacherScheduleDao, private val teacherDao: TeacherDao ) : TeacherScheduleRepository { + private val syncMutex = Mutex() + override fun observeSchedule(period: SchedulePeriod): Flow = combine( scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()), @@ -50,54 +57,60 @@ class TeacherScheduleRepositoryImpl( .map { names -> names.map { name -> Teacher(name = name) } } override suspend fun syncPeriod(date: LocalDate): Flow = flow { - val targets = withContext(Dispatchers.IO) { - teacherDao.getPeriodsForDate(date.toEpochDay()) - } - if (targets.isEmpty()) { - emit(BulkSyncProgress(0, 0, 0)) - return@flow - } - - targets.map { it.season to it.weekStart } - .distinct() - .forEach { (season, weekStart) -> - withContext(Dispatchers.IO) { - scheduleDao.deletePeriod(season.name, weekStart) - } + // Unique index (departmentId, season, weekStart) breaks on interleaved + // delete+insert from concurrent syncPeriod() callers. + syncMutex.withLock { + val targets = withContext(Dispatchers.IO) { + teacherDao.getPeriodsForDate(date.toEpochDay()) + } + if (targets.isEmpty()) { + emit(BulkSyncProgress(0, 0, 0)) + return@withLock } - val total = targets.size - val semaphore = Semaphore(PARALLELISM) - val jobs = coroutineScope { - targets.map { target -> - async { - semaphore.withPermit { - try { - withContext(Dispatchers.IO) { syncDepartment(target) } - true - } catch (e: Throwable) { - Log.e( - TAG, - "sync: failed for department ${target.departmentId} " + - "season ${target.season} week ${target.weekStart} " + - "(${e::class.simpleName}: ${e.message})", - e - ) - false + targets.map { it.season to it.weekStart } + .distinct() + .forEach { (season, weekStart) -> + withContext(Dispatchers.IO) { + scheduleDao.deletePeriod(season.name, weekStart) + } + } + + val total = targets.size + val semaphore = Semaphore(PARALLELISM) + val jobs = coroutineScope { + targets.map { target -> + async { + semaphore.withPermit { + try { + withContext(Dispatchers.IO) { syncDepartment(target) } + true + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.e( + TAG, + "sync: failed for department ${target.departmentId} " + + "season ${target.season} week ${target.weekStart} " + + "(${e::class.simpleName}: ${e.message})", + e + ) + false + } } } } } - } - var done = 0 - var failed = 0 - for (job in jobs) { - if (job.await()) done++ else failed++ - emit(BulkSyncProgress(total, done + failed, failed)) - } + var done = 0 + var failed = 0 + for (job in jobs) { + if (job.await()) done++ else failed++ + emit(BulkSyncProgress(total, done + failed, failed)) + } - withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) } + withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) } + } } override suspend fun hasData(period: SchedulePeriod): Boolean = @@ -105,6 +118,30 @@ class TeacherScheduleRepositoryImpl( scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay()) } + override suspend fun weeksFor(date: LocalDate): List = + withContext(Dispatchers.IO) { + teacherDao.getPeriodsForDate(date.toEpochDay()) + .distinctBy { it.season to (it.weekStart to it.weekEnd) } + .flatMap { window -> + val windowStart = LocalDate.ofEpochDay(window.weekStart) + val windowEnd = LocalDate.ofEpochDay(window.weekEnd) + generateSequence( + windowStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + ) { it.plusWeeks(1) } + .takeWhile { it <= windowEnd } + .map { monday -> + val weekEnd = monday.plusDays(6) + SchedulePeriod( + weekStart = monday, + weekEnd = if (weekEnd.isAfter(windowEnd)) windowEnd else weekEnd, + season = window.season + ) + } + } + .distinctBy { it.season to it.weekStart } + .sortedBy { it.weekStart } + } + override suspend fun clearAll() = withContext(Dispatchers.IO) { scheduleDao.deleteAll() } @@ -182,9 +219,13 @@ class TeacherScheduleRepositoryImpl( dayEntities: List, lessonEntities: List ): TeacherSchedule? { - if (dayEntities.isEmpty()) return null + val weekDays = dayEntities + .filter { + it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay() + } + if (weekDays.isEmpty()) return null val lessonsByDate = lessonEntities.groupBy { it.date } - val days = dayEntities + val days = weekDays .sortedBy { it.date } .distinctBy { it.date } .map { day -> diff --git a/app/src/main/java/agzam/vyatsu/di/AppContainer.kt b/app/src/main/java/agzam/vyatsu/di/AppContainer.kt index 3752b79..4552ac1 100644 --- a/app/src/main/java/agzam/vyatsu/di/AppContainer.kt +++ b/app/src/main/java/agzam/vyatsu/di/AppContainer.kt @@ -23,6 +23,7 @@ class AppContainer(context: Context) { AppDatabase::class.java, "vyatsu_schedule.db" ) + .addMigrations(AppDatabase.MIGRATION_3_4) .fallbackToDestructiveMigration(dropAllTables = true) .build() diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt index a685c76..b2e407c 100644 --- a/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt +++ b/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt @@ -12,5 +12,6 @@ interface TeacherScheduleRepository { fun observeSchedule(period: SchedulePeriod): Flow suspend fun syncPeriod(date: LocalDate): Flow suspend fun hasData(period: SchedulePeriod): Boolean + suspend fun weeksFor(date: LocalDate): List suspend fun clearAll() } \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/ui/picker/PickerViewModel.kt b/app/src/main/java/agzam/vyatsu/ui/picker/PickerViewModel.kt index a7be5c9..a3bf236 100644 --- a/app/src/main/java/agzam/vyatsu/ui/picker/PickerViewModel.kt +++ b/app/src/main/java/agzam/vyatsu/ui/picker/PickerViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import java.time.DayOfWeek import java.time.temporal.TemporalAdjusters +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,6 +46,7 @@ class PickerViewModel( val uiState: StateFlow = _uiState.asStateFlow() private var teachersJob: kotlinx.coroutines.Job? = null + private var syncJob: kotlinx.coroutines.Job? = null init { groupsLoad() @@ -139,11 +141,20 @@ class PickerViewModel( _uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true) - viewModelScope.launch { - runCatching { + syncJob?.cancel() + syncJob = viewModelScope.launch { + try { teacherScheduleRepository.syncPeriod(today).collect { progress -> _uiState.value = _uiState.value.copy(syncProgress = progress) } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + _uiState.value = _uiState.value.copy( + error = e.message ?: "Не удалось загрузить расписание преподавателей" + ) + } finally { + _uiState.value = _uiState.value.copy(teachersLoading = false) } } @@ -151,10 +162,11 @@ class PickerViewModel( teachersJob = viewModelScope.launch { teacherScheduleRepository.observeTeachers(period).collect { teachers -> val query = _uiState.value.teacherQuery + val isLoading = _uiState.value.teachersLoading _uiState.value = _uiState.value.copy( - teachersLoading = false, teachers = teachers, filteredTeachers = filterTeachers(teachers, query), + teachersLoading = isLoading && teachers.isEmpty(), error = null ) } @@ -178,11 +190,4 @@ class PickerViewModel( fun retryTeachers() { syncAndObserveTeachers() } - - fun selectTeacher(teacher: Teacher) { - viewModelScope.launch { - preferencesRepository.clearSelectedGroup() - preferencesRepository.setSelectedTeacherId(teacher.name) - } - } } \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt index 875ab4c..1dda35b 100644 --- a/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt +++ b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt @@ -132,13 +132,17 @@ fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: modifier = Modifier.weight(1f) ) WeekSelector( - periods = listOf(schedule.period), + periods = state.weeks.ifEmpty { listOf(schedule.period) }, selectedWeekStart = state.weekStart, - todayWeekStart = state.todayDate?.let { todayDate -> - schedule.period.weekStart.takeIf { - !todayDate.isBefore(it) && !todayDate.isAfter(it.plusDays(6)) + todayWeekStart = state.todayDate + ?.let { todayDate -> + state.weeks.firstOrNull { period -> + !todayDate.isBefore(period.weekStart) && !todayDate.isAfter(period.weekEnd) + }?.weekStart } - }, + ?: schedule.period.weekStart.takeIf { + state.todayDate?.let { today -> !today.isBefore(it) && !today.isAfter(it.plusDays(6)) } == true + }, onSelect = viewModel::selectWeek ) } diff --git a/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt index 815afb9..59d79b8 100644 --- a/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt +++ b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope import java.time.DayOfWeek import java.time.LocalDate import java.time.temporal.TemporalAdjusters +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,6 +28,8 @@ class TeacherScheduleViewModel( private var currentTeacherName: String? = null private var currentPeriod: SchedulePeriod? = null private var scheduleJob: Job? = null + private var weeksJob: Job? = null + private var loadRevision = 0 init { viewModelScope.launch { @@ -40,52 +43,76 @@ class TeacherScheduleViewModel( } fun selectTeacher(name: String) { + val revision = ++loadRevision currentTeacherName = name _uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false) viewModelScope.launch { - preferencesRepository.setSelectedTeacherId(name) + try { + preferencesRepository.setSelectedTeacherId(name) - val savedWeekStart = runCatching { - preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse) - }.getOrNull() - val savedDayDate = runCatching { - preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse) - }.getOrNull() + val savedWeekStart = runCatching { + preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse) + }.getOrNull() + val savedDayDate = runCatching { + preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse) + }.getOrNull() - val today = ScheduleDates.moscowToday() - val weekStart = savedWeekStart - ?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - _uiState.value = _uiState.value.copy( - noTeacher = false, - teacherName = name, - weekStart = weekStart, - dayDate = savedDayDate - ) + val today = ScheduleDates.moscowToday() + val weekStart = savedWeekStart + ?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + if (revision != loadRevision) return@launch + _uiState.value = _uiState.value.copy( + noTeacher = false, + teacherName = name, + weekStart = weekStart, + dayDate = savedDayDate + ) - val period = SchedulePeriod( - weekStart = weekStart, - weekEnd = weekStart.plusDays(6), - season = ScheduleDates.currentSeason(today) - ) - loadSchedule(period) + val period = SchedulePeriod( + weekStart = weekStart, + weekEnd = weekStart.plusDays(6), + season = ScheduleDates.currentSeason(today) + ) + loadSchedule(period, revision) + } catch (e: CancellationException) { + throw e + } } } - private suspend fun loadSchedule(period: SchedulePeriod) { + private suspend fun loadSchedule(period: SchedulePeriod, revision: Int) { + if (revision != loadRevision) return scheduleJob?.cancel() scheduleJob = null currentPeriod = period if (!scheduleRepository.hasData(period)) { _uiState.value = _uiState.value.copy(isLoading = true, loadError = false) - runCatching { scheduleRepository.syncPeriod(period.weekStart).collect { } } + try { + scheduleRepository.syncPeriod(period.weekStart).collect { } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + } + if (revision != loadRevision) return if (!scheduleRepository.hasData(period)) { _uiState.value = _uiState.value.copy(isLoading = false, loadError = true) + return } } + weeksJob?.cancel() + weeksJob = viewModelScope.launch { + val weeks = scheduleRepository.weeksFor(period.weekStart) + if (revision != loadRevision) return@launch + _uiState.value = _uiState.value.copy(weeks = weeks) + } + scheduleJob = viewModelScope.launch { scheduleRepository.observeSchedule(period).collect { schedule -> + if (revision != loadRevision) { + throw CancellationException("stale schedule observation") + } currentTeacherName?.let { applySchedule(schedule, it) } } } @@ -122,21 +149,27 @@ class TeacherScheduleViewModel( val state = _uiState.value val name = state.teacherName ?: return if (state.weekStart == weekStart) return + val revision = ++loadRevision viewModelScope.launch { - preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString()) - _uiState.value = _uiState.value.copy( - weekStart = weekStart, - dayDate = null, - schedule = null, - isLoading = true, - loadError = false - ) - val period = SchedulePeriod( - weekStart = weekStart, - weekEnd = weekStart.plusDays(6), - season = ScheduleDates.currentSeason(weekStart) - ) - loadSchedule(period) + try { + preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString()) + if (revision != loadRevision) return@launch + _uiState.value = _uiState.value.copy( + weekStart = weekStart, + dayDate = null, + schedule = null, + isLoading = true, + loadError = false + ) + val period = SchedulePeriod( + weekStart = weekStart, + weekEnd = weekStart.plusDays(6), + season = ScheduleDates.currentSeason(weekStart) + ) + loadSchedule(period, revision) + } catch (e: CancellationException) { + throw e + } } } @@ -153,14 +186,26 @@ class TeacherScheduleViewModel( fun refresh() { val period = currentPeriod ?: return + val revision = ++loadRevision viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true, loadError = false) - runCatching { scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { } } - loadSchedule(period) + try { + _uiState.value = _uiState.value.copy(isLoading = true, loadError = false) + try { + scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + } + if (revision != loadRevision) return@launch + loadSchedule(period, revision) + } catch (e: CancellationException) { + throw e + } } } fun switchTeacher() { + loadRevision++ viewModelScope.launch { currentTeacherName = null currentPeriod = null @@ -183,5 +228,6 @@ data class TeacherScheduleUiState( val teacherName: String? = null, val weekStart: LocalDate? = null, val dayDate: LocalDate? = null, - val todayDate: LocalDate? = null + val todayDate: LocalDate? = null, + val weeks: List = emptyList() ) \ No newline at end of file