Fix race condition in concurrent append/prepend

This commit is contained in:
FooIbar committed 2025-09-28 13:31:21 +08:00
1 parent 5ba0afdd24
commit 1b9c3050aa
8 files changed
+68 -58

No files matched your search

@@ -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) {
@@ -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) {
@@ -419,7 +419,7 @@ suspend fun showMoveDownloadLabelList(list: Collection<DownloadInfo>): 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)
@@ -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()
@@ -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<String, BaseGalleryInfo>() {
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
override suspend fun load(params: LoadParams<String>) = 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
@@ -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..<TOPLIST_PAGES) { outOfRange }
}.trim().toInt() - 1
urlBuilder.setJumpTo(text)
val goto = ensureNotNull(oriText.trim().toIntOrNull()) { invalidNum }
ensure(goto in 1..TOPLIST_PAGES) { outOfRange }
}
urlBuilder.page = text.trim().toInt()
} else {
val date = awaitSelectDate()
urlBuilder.jumpTo = date
urlBuilder.setSeek(date)
}
data.refresh()
}
onClick(Icons.AutoMirrored.Default.LastPage) {
if (isTopList) {
urlBuilder.setJumpTo(TOPLIST_PAGES - 1)
urlBuilder.page = TOPLIST_PAGES
} else {
urlBuilder.setIndex("1", false)
}
@@ -17,49 +17,54 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TOPLIST
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.tarsin.coroutines.runSuspendCatching
class GalleryListViewModel(lub: ListUrlBuilder, savedStateHandle: SavedStateHandle) : ViewModel() {
private val mutex = Mutex()
val urlBuilder by savedStateHandle.saved(MutableStateSerializer()) {
mutableStateOf(lub)
}
val data = Pager(PagingConfig(25)) {
// Use a smaller prefetch distance to avoid prepending an additional page after jumping
val data = Pager(PagingConfig(25, prefetchDistance = 20)) {
object : PagingSource<String, BaseGalleryInfo>() {
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
override suspend fun load(params: LoadParams<String>) = 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)
}
}
@@ -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))
}
},