From 1b9c3050aaabb1696f19cefed3a630366d9bdeca Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Sun, 28 Sep 2025 12:30:05 +0800 Subject: [PATCH] Fix race condition in concurrent append/prepend --- .../ehviewer/client/data/FavListUrlBuilder.kt | 7 ++-- .../ehviewer/client/data/ListUrlBuilder.kt | 20 +++++---- .../com/hippo/ehviewer/ui/CommonOperations.kt | 5 +-- .../ehviewer/ui/screen/FavoritesScreen.kt | 2 +- .../ehviewer/ui/screen/FavoritesViewModel.kt | 32 +++++++-------- .../ehviewer/ui/screen/GalleryListScreen.kt | 15 ++++--- .../ui/screen/GalleryListViewModel.kt | 41 +++++++++++-------- .../com/hippo/ehviewer/ui/tools/Dialog.kt | 4 +- 8 files changed, 68 insertions(+), 58 deletions(-) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt index 9b8db11f4..2ab53023e 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt @@ -25,9 +25,9 @@ import kotlinx.serialization.Serializable data class FavListUrlBuilder( val favCat: Int = FAV_CAT_ALL, val keyword: String? = null, - var jumpTo: String? = null, - var prev: String? = null, - var next: String? = null, + private var jumpTo: String? = null, + private var prev: String? = null, + private var next: String? = null, ) { val isLocal get() = favCat == FAV_CAT_LOCAL @@ -35,6 +35,7 @@ data class FavListUrlBuilder( fun setIndex(index: String?, isNext: Boolean) { next = index.takeIf { isNext } prev = index.takeUnless { isNext } + jumpTo = null } fun build() = ehUrl(EhUrl.FAV_PATH) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt index 3b3591b29..b71fdbda1 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt @@ -24,16 +24,14 @@ import com.hippo.ehviewer.client.ehUrl import com.hippo.ehviewer.dao.QuickSearch import com.hippo.ehviewer.ui.main.AdvanceTable import io.ktor.http.Parameters -import kotlin.text.toIntOrNull import kotlinx.serialization.Serializable @Serializable data class ListUrlBuilder( var mode: Int = MODE_NORMAL, private var prev: String? = null, - var next: String? = null, - // Reset to null after initial loading - var jumpTo: String? = null, + private var next: String? = null, + private var jumpTo: String? = null, private var range: Int = 0, var category: Int = EhUtils.NONE, private var mKeyword: String? = null, @@ -49,11 +47,14 @@ data class ListUrlBuilder( next = index.takeIf { isNext } prev = index.takeUnless { isNext } range = 0 + jumpTo = null } - fun setJumpTo(to: Int) { - jumpTo = to.takeUnless { it == 0 }?.toString() - } + var page: Int + get() = (jumpTo?.toInt() ?: 0) + 1 + set(value) { + jumpTo = (value - 1).takeUnless { it == 0 }?.toString() + } fun setRange(to: Int) { range = to @@ -62,6 +63,11 @@ data class ListUrlBuilder( jumpTo = null } + fun setSeek(date: String) { + setIndex("2", true) + jumpTo = date + } + var keyword: String? get() = if (MODE_UPLOADER == mode) "uploader:$mKeyword" else mKeyword set(keyword) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt index 9fd14d1a9..afb890ca3 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/CommonOperations.kt @@ -419,7 +419,7 @@ suspend fun showMoveDownloadLabelList(list: Collection): String? { } context(_: DialogState) -suspend fun awaitSelectDate(): String? { +suspend fun awaitSelectDate(): String { val initial = LocalDate(2007, 3, 21) val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY) val initialMillis = initial.toEpochMillis() @@ -432,8 +432,7 @@ suspend fun awaitSelectDate(): String? { override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in dateRange }, ) - val date = dateMillis?.run { toLocalDateTime().date.toString() } - return date + return dateMillis.toLocalDateTime().date.toString() } context(_: Context, _: DialogState) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt index 6a5f216c3..3ebc8ab31 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt @@ -313,7 +313,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v } onClick(EhIcons.Default.GoTo) { val date = awaitSelectDate() - refresh(urlBuilder.copy(jumpTo = date)) + refresh(urlBuilder.copy(jumpTo = date, prev = null, next = "2")) } onClick(Icons.Default.Refresh) { refresh() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesViewModel.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesViewModel.kt index 16246a113..55e5a5d0e 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesViewModel.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesViewModel.kt @@ -23,9 +23,13 @@ import com.hippo.ehviewer.client.data.FavListUrlBuilder import com.hippo.ehviewer.ui.tools.foldToLoadResult import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import moe.tarsin.coroutines.runSuspendCatching class FavoritesViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val mutex = Mutex() + val urlBuilder by savedStateHandle.saved(MutableStateSerializer()) { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) } @@ -43,32 +47,26 @@ class FavoritesViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { } } } else { - Pager(PagingConfig(25)) { + Pager(PagingConfig(DEFAULT_PAGE_SIZE, prefetchDistance = 20)) { object : PagingSource() { override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams) = withIOContext { - val urlBuilder = urlBuilder.value - when (params) { - is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false) - is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true) - is LoadParams.Refresh -> { - val key = params.key - if (key.isNullOrBlank()) { - if (urlBuilder.jumpTo != null) { - urlBuilder.next ?: urlBuilder.setIndex("2", true) - } - } else { - urlBuilder.setIndex(key, false) + val url = mutex.withLock { + with(urlBuilder.value) { + when (params) { + is LoadParams.Prepend -> setIndex(params.key, isNext = false) + is LoadParams.Append -> setIndex(params.key, isNext = true) + is LoadParams.Refresh -> params.key?.let { setIndex(it, false) } } + build() } } runSuspendCatching { - EhEngine.getFavorites(urlBuilder.build()) + EhEngine.getFavorites(url) }.foldToLoadResult { result -> Settings.favCat = result.catArray.toTypedArray() Settings.favCount = result.countArray.toIntArray() Settings.favCloudCount = result.countArray.sum() - urlBuilder.jumpTo = null LoadResult.Page(result.galleryInfoList, result.prev, result.next) } } @@ -77,8 +75,10 @@ class FavoritesViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { }.flow.map { pagingData -> // https://github.com/FooIbar/EhViewer/issues/1190 // Workaround for duplicate items when sorting by favorited time - val gidSet = MutableLongSet(50) + val gidSet = MutableLongSet(DEFAULT_PAGE_SIZE) pagingData.filter { gidSet.add(it.gid) } } }.cachedIn(viewModelScope) } + +private const val DEFAULT_PAGE_SIZE = 50 diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index f6cb86892..7255a59c6 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -590,22 +590,21 @@ fun AnimatedVisibilityScope.GalleryListScreen( if (urlBuilder.mode != MODE_WHATS_HOT) { onClick(EhIcons.Default.GoTo) { if (isTopList) { - val page = urlBuilder.jumpTo?.toIntOrNull() ?: 0 - val hint = string(R.string.go_to_hint, page + 1, TOPLIST_PAGES) + val hint = string(R.string.go_to_hint, urlBuilder.page, TOPLIST_PAGES) val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText -> - val goto = ensureNotNull(oriText.trim().toIntOrNull()) { invalidNum } - 1 - ensure(goto in 0..() { override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams) = withIOContext { val urlBuilder = urlBuilder.value if (urlBuilder.mode == MODE_TOPLIST) { // TODO: Since we know total pages, let pager support jump - val key = (params.key ?: urlBuilder.jumpTo)?.toInt() ?: 0 + val key: Int + val url = mutex.withLock { + with(urlBuilder) { + params.key?.let { page = it.toInt() } + key = page + build() + } + } val prev = (key - 1).takeIf { it > 0 } - val next = (key + 1).takeIf { it < TOPLIST_PAGES } + val next = (key + 1).takeIf { it <= TOPLIST_PAGES } runSuspendCatching { - urlBuilder.setJumpTo(key) - EhEngine.getGalleryList(urlBuilder.build()) + EhEngine.getGalleryList(url) }.foldToLoadResult { result -> LoadResult.Page(result.galleryInfoList, prev?.toString(), next?.toString()) } } else { - when (params) { - is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false) - is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true) - is LoadParams.Refresh -> { - val key = params.key - if (key.isNullOrBlank()) { - if (urlBuilder.jumpTo != null) { - urlBuilder.next ?: urlBuilder.setIndex("2", true) - } - } else { - urlBuilder.setIndex(key, false) + val url = mutex.withLock { + with(urlBuilder) { + when (params) { + is LoadParams.Prepend -> setIndex(params.key, isNext = false) + is LoadParams.Append -> setIndex(params.key, isNext = true) + is LoadParams.Refresh -> params.key?.let { setIndex(it, false) } } + build() } } runSuspendCatching { - val url = urlBuilder.build() EhEngine.getGalleryList(url) }.foldToLoadResult { result -> - urlBuilder.jumpTo = null LoadResult.Page(result.galleryInfoList, result.prev, result.next) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt index 7a2cf7cba..78260b276 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt @@ -487,7 +487,7 @@ suspend fun awaitSelectDate( initialDisplayMode: DisplayMode = DisplayMode.Picker, selectableDates: SelectableDates = DatePickerDefaults.AllDates, showModeToggle: Boolean = true, -): Long? = dialog { cont -> +): Long = dialog { cont -> val state = rememberDatePickerState( initialSelectedDateMillis, initialDisplayedMonthMillis, @@ -498,7 +498,7 @@ suspend fun awaitSelectDate( DatePickerDialog( onDismissRequest = { cont.cancel() }, confirmButton = { - TextButton(onClick = { cont.resume(state.selectedDateMillis) }, shapes = ButtonDefaults.shapes()) { + TextButton(onClick = { state.selectedDateMillis?.let { cont.resume(it) } ?: cont.cancel() }, shapes = ButtonDefaults.shapes()) { Text(text = stringResource(id = android.R.string.ok)) } },