🛠️ Fix not thread-safe _state.update() calls

* Fixed multiple issues with update calls being executed simultaneously
This commit is contained in:
Acclorite 2024-12-28 16:02:16 +02:00
parent a25cb8f3eb
commit bc9c378e43
9 changed files with 344 additions and 226 deletions

View file

@ -10,8 +10,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.use_case.remote.CheckForUpdates
@ -25,6 +26,8 @@ class AboutModel @Inject constructor(
private val checkForUpdates: CheckForUpdates
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(AboutState())
val state = _state.asStateFlow()
@ -100,6 +103,7 @@ class AboutModel @Inject constructor(
}
is AboutEvent.OnDismissDialog -> {
viewModelScope.launch {
_state.update {
it.copy(dialog = null)
}
@ -107,3 +111,10 @@ class AboutModel @Inject constructor(
}
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -18,8 +18,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
@ -55,6 +56,8 @@ class BookInfoModel @Inject constructor(
private val updateBookWithText: UpdateBookWithText
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(BookInfoState())
val state = _state.asStateFlow()
@ -384,15 +387,16 @@ class BookInfoModel @Inject constructor(
is BookInfoEvent.OnCheckCoverReset -> {
launch(Dispatchers.IO) {
if (_state.value.book.id == -1) return@launch
canResetCover.execute(_state.value.book.id).apply {
_state.update {
it.copy(
canResetCover = canResetCover.execute(
_state.value.book.id
)
canResetCover = this
)
}
}
}
}
is BookInfoEvent.OnShowMoreBottomSheet -> {
_state.update {
@ -780,16 +784,16 @@ class BookInfoModel @Inject constructor(
return@launch
}
eventJob.cancel()
eventJob.join()
eventJob = SupervisorJob()
_state.update {
BookInfoState(
book = book
)
}
eventJob.cancel()
eventJob.join()
eventJob = SupervisorJob()
if (startUpdate) {
onEvent(
BookInfoEvent.OnCheckForTextUpdate(
@ -804,13 +808,14 @@ class BookInfoModel @Inject constructor(
fun resetScreen() {
viewModelScope.launch(Dispatchers.Main) {
_state.update {
BookInfoState()
}
eventJob.cancel()
eventJob.join()
eventJob = SupervisorJob()
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -18,8 +18,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
@ -47,6 +48,8 @@ class BrowseModel @Inject constructor(
private val insertBook: InsertBook
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(BrowseState())
val state = _state.asStateFlow()
@ -119,6 +122,7 @@ class BrowseModel @Inject constructor(
}
is BrowseEvent.OnRequestFocus -> {
viewModelScope.launch(Dispatchers.Main) {
if (!_state.value.hasFocused) {
event.focusRequester.requestFocus()
_state.update {
@ -128,20 +132,23 @@ class BrowseModel @Inject constructor(
}
}
}
}
is BrowseEvent.OnSearchQueryChange -> {
viewModelScope.launch {
_state.update {
it.copy(
searchQuery = event.query
)
}
changeSearchQueryJob?.cancel()
changeSearchQueryJob = viewModelScope.launch(Dispatchers.IO) {
changeSearchQueryJob = launch(Dispatchers.IO) {
delay(500)
yield()
onEvent(BrowseEvent.OnSearch)
}
}
}
is BrowseEvent.OnSearch -> {
viewModelScope.launch(Dispatchers.IO) {
@ -292,20 +299,24 @@ class BrowseModel @Inject constructor(
}
is BrowseEvent.OnShowFilterBottomSheet -> {
viewModelScope.launch {
_state.update {
it.copy(
bottomSheet = BrowseScreen.FILTER_BOTTOM_SHEET
)
}
}
}
is BrowseEvent.OnDismissBottomSheet -> {
viewModelScope.launch {
_state.update {
it.copy(
bottomSheet = null
)
}
}
}
is BrowseEvent.OnPermissionCheck -> {
viewModelScope.launch(Dispatchers.IO) {
@ -413,6 +424,7 @@ class BrowseModel @Inject constructor(
}
is BrowseEvent.OnDismissPermissionDialog -> {
viewModelScope.launch {
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val isPermissionGranted = if (!legacyPermission) {
Environment.isExternalStorageManager()
@ -429,6 +441,7 @@ class BrowseModel @Inject constructor(
_state.update { it.copy(isError = true) }
}
}
}
is BrowseEvent.OnShowAddDialog -> {
viewModelScope.launch {
@ -484,6 +497,7 @@ class BrowseModel @Inject constructor(
}
is BrowseEvent.OnDismissAddDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = null
@ -491,6 +505,7 @@ class BrowseModel @Inject constructor(
}
getAddDialogBooksJob?.cancel()
}
}
is BrowseEvent.OnActionAddDialog -> {
viewModelScope.launch {
@ -574,6 +589,7 @@ class BrowseModel @Inject constructor(
}
is BrowseEvent.OnDismissDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = null
@ -582,6 +598,7 @@ class BrowseModel @Inject constructor(
}
}
}
}
private suspend fun getFilesFromDownloads(
query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
@ -601,9 +618,11 @@ class BrowseModel @Inject constructor(
}
fun resetScreen() {
viewModelScope.launch {
storagePermissionJob?.cancel()
_state.update { it.copy(isError = false) }
}
}
fun filterList(
files: List<SelectableFile>,
@ -710,4 +729,10 @@ class BrowseModel @Inject constructor(
)
)
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -11,8 +11,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
@ -42,6 +43,8 @@ class HistoryModel @Inject constructor(
private val deleteWholeHistory: DeleteWholeHistory
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(HistoryState())
val state = _state.asStateFlow()
@ -136,6 +139,7 @@ class HistoryModel @Inject constructor(
}
is HistoryEvent.OnRequestFocus -> {
viewModelScope.launch(Dispatchers.Main) {
if (!_state.value.hasFocused) {
event.focusRequester.requestFocus()
_state.update {
@ -145,20 +149,23 @@ class HistoryModel @Inject constructor(
}
}
}
}
is HistoryEvent.OnSearchQueryChange -> {
viewModelScope.launch {
_state.update {
it.copy(
searchQuery = event.query
)
}
searchQueryChange?.cancel()
searchQueryChange = viewModelScope.launch(Dispatchers.IO) {
searchQueryChange = launch(Dispatchers.IO) {
delay(500)
yield()
onEvent(HistoryEvent.OnSearch)
}
}
}
is HistoryEvent.OnSearch -> {
viewModelScope.launch(Dispatchers.IO) {
@ -209,12 +216,14 @@ class HistoryModel @Inject constructor(
}
is HistoryEvent.OnShowDeleteWholeHistoryDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = HistoryScreen.DELETE_WHOLE_HISTORY_DIALOG
)
}
}
}
is HistoryEvent.OnActionDeleteWholeHistoryDialog -> {
viewModelScope.launch {
@ -238,6 +247,7 @@ class HistoryModel @Inject constructor(
}
is HistoryEvent.OnDismissDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = null
@ -246,6 +256,7 @@ class HistoryModel @Inject constructor(
}
}
}
}
private suspend fun getHistoryFromDatabase(
query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
@ -314,4 +325,10 @@ class HistoryModel @Inject constructor(
)
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -10,8 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
@ -32,6 +33,8 @@ class LibraryModel @Inject constructor(
private val moveBooks: UpdateBook
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(LibraryState())
val state = _state.asStateFlow()
@ -104,18 +107,20 @@ class LibraryModel @Inject constructor(
}
is LibraryEvent.OnSearchQueryChange -> {
viewModelScope.launch {
_state.update {
it.copy(
searchQuery = event.query
)
}
searchQueryChange?.cancel()
searchQueryChange = viewModelScope.launch(Dispatchers.IO) {
searchQueryChange = launch(Dispatchers.IO) {
delay(500)
yield()
onEvent(LibraryEvent.OnSearch)
}
}
}
is LibraryEvent.OnSearch -> {
viewModelScope.launch(Dispatchers.IO) {
@ -124,6 +129,7 @@ class LibraryModel @Inject constructor(
}
is LibraryEvent.OnRequestFocus -> {
viewModelScope.launch(Dispatchers.Main) {
if (!_state.value.hasFocused) {
event.focusRequester.requestFocus()
_state.update {
@ -133,6 +139,7 @@ class LibraryModel @Inject constructor(
}
}
}
}
is LibraryEvent.OnClearSelectedBooks -> {
viewModelScope.launch(Dispatchers.IO) {
@ -163,12 +170,14 @@ class LibraryModel @Inject constructor(
}
is LibraryEvent.OnShowMoveDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = LibraryScreen.MOVE_DIALOG
)
}
}
}
is LibraryEvent.OnActionMoveDialog -> {
viewModelScope.launch {
@ -213,12 +222,14 @@ class LibraryModel @Inject constructor(
}
is LibraryEvent.OnShowDeleteDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = LibraryScreen.DELETE_DIALOG
)
}
}
}
is LibraryEvent.OnActionDeleteDialog -> {
viewModelScope.launch {
@ -249,6 +260,7 @@ class LibraryModel @Inject constructor(
}
is LibraryEvent.OnDismissDialog -> {
viewModelScope.launch {
_state.update {
it.copy(
dialog = null
@ -257,6 +269,7 @@ class LibraryModel @Inject constructor(
}
}
}
}
private suspend fun getBooksFromDatabase(
query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
@ -273,4 +286,10 @@ class LibraryModel @Inject constructor(
)
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -11,8 +11,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.domain.browse.toBrowseFilesStructure
import ua.acclorite.book_story.domain.browse.toBrowseLayout
@ -45,6 +46,8 @@ class MainModel @Inject constructor(
private val getAllSettings: GetAllSettings
) : ViewModel() {
private val mutex = Mutex()
private val _isReady = MutableStateFlow(false)
val isReady = _isReady.asStateFlow()
@ -506,4 +509,10 @@ class MainModel @Inject constructor(
}
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -22,8 +22,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
@ -54,6 +55,8 @@ class ReaderModel @Inject constructor(
private val checkForTextUpdate: CheckForTextUpdate
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(ReaderState())
val state = _state.asStateFlow()
@ -550,13 +553,14 @@ class ReaderModel @Inject constructor(
return@launch
}
eventJob.cancel()
eventJob.join()
eventJob = SupervisorJob()
_state.update {
ReaderState(book = book)
}
eventJob.cancel()
eventJob = SupervisorJob()
onEvent(
ReaderEvent.OnMenuVisibility(
show = false,
@ -606,6 +610,7 @@ class ReaderModel @Inject constructor(
}
private fun updateChapter(index: Int) {
viewModelScope.launch {
val (currentChapter, currentChapterProgress) = calculateCurrentChapter(index)
_state.update {
it.copy(
@ -614,6 +619,7 @@ class ReaderModel @Inject constructor(
)
}
}
}
private fun calculateCurrentChapter(index: Int): Pair<Chapter?, Float> {
val currentChapter = _state.value.book.chapters.find { chapter ->
@ -672,12 +678,14 @@ class ReaderModel @Inject constructor(
fun resetScreen() {
viewModelScope.launch(Dispatchers.Main) {
_state.update {
ReaderState()
}
eventJob.cancel()
eventJob = SupervisorJob()
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -15,8 +15,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
@ -42,6 +43,8 @@ class SettingsModel @Inject constructor(
private val deleteColorPreset: DeleteColorPreset
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(SettingsState())
val state = _state.asStateFlow()
@ -536,4 +539,10 @@ class SettingsModel @Inject constructor(
updateColorColorPresetJob?.cancel()
deleteColorPresetJob?.cancel()
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}

View file

@ -16,8 +16,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import ua.acclorite.book_story.presentation.core.util.launchActivity
import javax.inject.Inject
@ -27,6 +28,8 @@ class StartModel @Inject constructor(
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(StartState())
val state = _state.asStateFlow()
@ -59,6 +62,7 @@ class StartModel @Inject constructor(
}
is StartEvent.OnStoragePermissionRequest -> {
viewModelScope.launch {
val legacyStoragePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val isPermissionGranted = if (legacyStoragePermission) {
@ -71,7 +75,7 @@ class StartModel @Inject constructor(
storagePermissionGranted = true
)
}
return
return@launch
}
if (legacyStoragePermission) {
@ -82,7 +86,7 @@ class StartModel @Inject constructor(
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
intent.launchActivity(event.activity) {
return
return@launch
}
}
}
@ -95,7 +99,7 @@ class StartModel @Inject constructor(
)
intent.launchActivity(event.activity) {
return
return@launch
}
}
@ -123,15 +127,17 @@ class StartModel @Inject constructor(
}
}
}
}
is StartEvent.OnNotificationsPermissionRequest -> {
viewModelScope.launch {
if (event.notificationsPermissionState.status.isGranted) {
_state.update {
it.copy(
notificationsPermissionGranted = true
)
}
return
return@launch
}
if (!event.notificationsPermissionState.status.shouldShowRationale) {
@ -141,7 +147,7 @@ class StartModel @Inject constructor(
intent.putExtra(Settings.EXTRA_APP_PACKAGE, event.activity.packageName)
intent.launchActivity(event.activity) {
return
return@launch
}
}
@ -168,8 +174,10 @@ class StartModel @Inject constructor(
}
}
}
}
fun resetScreen() {
viewModelScope.launch {
_state.update {
storagePermissionJob?.cancel()
notificationsPermissionJob?.cancel()
@ -178,3 +186,10 @@ class StartModel @Inject constructor(
}
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
this.value = function(this.value)
}
}
}