Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ interface LibraryDao {
@Query("UPDATE library_items SET isDownloaded = :downloaded, downloadedAt = :timestamp WHERE id = :id")
suspend fun setDownloaded(id: String, downloaded: Boolean, timestamp: Long?)

@Query("UPDATE library_items SET totalChapters = :totalChapters WHERE id = :id")
suspend fun updateTotalChapters(id: String, totalChapters: Int)

@Query("UPDATE library_items SET hasUpdates = 1 WHERE id = :id")
suspend fun markHasUpdates(id: String)

@Query("SELECT * FROM library_items WHERE isDownloaded = 1")
suspend fun getDownloadedItems(): List<LibraryItem>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ class ChapterListCache @Inject constructor(
baseNovelUrl = baseNovelUrl,
sourceName = sourceName
)
file.writeText(json.encodeToString(Entry.serializer(), entry))
val temp = File.createTempFile("${file.name}.", ".tmp", file.parentFile)
try {
temp.writeText(json.encodeToString(Entry.serializer(), entry))
if (!temp.renameTo(file)) {
temp.copyTo(file, overwrite = true)
}
} finally {
temp.delete()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.aatricks.easyreader.data.model.*
import io.aatricks.easyreader.data.repository.content.*
import io.aatricks.easyreader.data.repository.content.StorageTier
import io.aatricks.easyreader.util.FileSizeUtils
import io.aatricks.easyreader.util.rethrowCancellation
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.CancellationException
Expand Down Expand Up @@ -235,7 +236,7 @@ class ContentRepository @Inject constructor(
isRetryable = false
)
}
}.getOrElse {
}.rethrowCancellation().getOrElse {
PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true)
}
trimCachesInternal(force = true)
Expand Down Expand Up @@ -285,7 +286,7 @@ class ContentRepository @Inject constructor(
isPersistentDownload = true
)
}
}.getOrElse {
}.rethrowCancellation().getOrElse {
PrefetchResult(
url,
htmlCached = false,
Expand Down Expand Up @@ -328,7 +329,7 @@ class ContentRepository @Inject constructor(
isPersistentDownload = true
)
}
}.getOrElse {
}.rethrowCancellation().getOrElse {
PrefetchResult(
url,
htmlCached = false,
Expand Down Expand Up @@ -363,7 +364,7 @@ class ContentRepository @Inject constructor(
isRetryable = false
)
}
}.getOrElse {
}.rethrowCancellation().getOrElse {
PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.aatricks.easyreader.data.repository

import io.aatricks.easyreader.util.TextUtils
import io.aatricks.easyreader.util.normalizeChapterList
import io.aatricks.easyreader.util.rethrowCancellation
import io.aatricks.easyreader.data.local.PreferencesManager
import io.aatricks.easyreader.data.local.LibraryDao
import io.aatricks.easyreader.data.model.LibraryItem
Expand Down Expand Up @@ -82,6 +83,7 @@ class LibraryRepository @Inject constructor(
block: suspend () -> T
): T? = withContext(Dispatchers.IO) {
kotlin.runCatching { block() }
.rethrowCancellation()
.onFailure { e -> Log.e(TAG, errorMessage, e) }
.getOrDefault(fallback)
}
Expand Down Expand Up @@ -369,7 +371,11 @@ class LibraryRepository @Inject constructor(
): Unit = io {
runRepoCatching("Refresh updates failed") {
val allItems = libraryDao.getAllItems().firstOrNull() ?: emptyList()
val groupedItems = getGroupedByTitle(allItems)
val groupedItems = allItems.groupBy { item ->
Pair(item.baseTitle.ifBlank { item.title }, item.sourceName)
}.mapValues { (_, group) ->
sortChapters(group)
}
val semaphore = Semaphore(5)

// Only check for updates on novels that have been read recently or were added recently.
Expand All @@ -385,19 +391,20 @@ class LibraryRepository @Inject constructor(
})
}

val channel = Channel<Pair<String, List<LibraryItem>>>(Channel.UNLIMITED)
val channel = Channel<Pair<Pair<String, String>, List<LibraryItem>>>(Channel.UNLIMITED)
activeGroups.forEach { channel.trySend(it.key to it.value) }
channel.close()

val allUpdates = coroutineScope {
val workers = (1..5).map {
async {
val localUpdates = mutableListOf<LibraryItem>()
for ((baseTitle, items) in channel) {
val localUpdates = mutableListOf<LibraryUpdate>()
for ((key, items) in channel) {
val (baseTitle, _) = key
if (items.isNotEmpty()) {
val latestInLibrary = items.last()
if (latestInLibrary.baseNovelUrl.isNotBlank() && latestInLibrary.sourceName.isNotBlank()) {
val newUpdates = runRepoCatching("Failed to refresh updates for $baseTitle", emptyList<LibraryItem>()) {
val newUpdates = runRepoCatching("Failed to refresh updates for $baseTitle", emptyList<LibraryUpdate>()) {
val details = withTimeoutOrNull(REFRESH_PER_SOURCE_TIMEOUT_MS) {
exploreRepository.getNovelDetails(
latestInLibrary.baseNovelUrl,
Expand All @@ -415,17 +422,20 @@ class LibraryRepository @Inject constructor(
markerChapterNumber >= previousTotalChapters.toDouble() &&
itemToMark.hasFinishedProgress()
items.map { item ->
var newItem = item.copy(totalChapters = sourceChapterCount)
if (wasCaughtUp && item.id == itemToMark.id && !item.hasUpdates) {
newItem = newItem.copy(hasUpdates = true)
}
newItem
val markUpdate = wasCaughtUp &&
item.id == itemToMark.id &&
!item.hasUpdates
LibraryUpdate(
itemId = item.id,
newTotalChapters = sourceChapterCount,
markHasUpdates = markUpdate
)
}
} else {
emptyList<LibraryItem>()
emptyList()
}
} else {
emptyList<LibraryItem>()
emptyList()
}
} ?: emptyList()
localUpdates.addAll(newUpdates)
Expand All @@ -439,7 +449,12 @@ class LibraryRepository @Inject constructor(
}

if (allUpdates.isNotEmpty()) {
libraryDao.insertItems(allUpdates)
allUpdates.forEach { update ->
libraryDao.updateTotalChapters(update.itemId, update.newTotalChapters)
if (update.markHasUpdates) {
libraryDao.markHasUpdates(update.itemId)
}
}
}
}
}
Expand Down Expand Up @@ -473,4 +488,9 @@ class LibraryRepository @Inject constructor(
} ?: false
} ?: false

private data class LibraryUpdate(
val itemId: String,
val newTotalChapters: Int,
val markHasUpdates: Boolean
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import io.aatricks.easyreader.ui.components.applyReaderEdgeBlur
import io.aatricks.easyreader.ui.components.supportsReaderEdgeBlur
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.abs

Expand Down Expand Up @@ -134,15 +135,18 @@ internal fun ContentArea(
if (uiState.showControls) edgeBlurCaptureGeneration++
}

LaunchedEffect(
uiState.showControls,
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
pagerState.currentPage
) {
LaunchedEffect(uiState.showControls, content.url) {
if (!uiState.showControls) return@LaunchedEffect
kotlinx.coroutines.delay(EDGE_BLUR_RECAPTURE_DEBOUNCE_MS)
edgeBlurCaptureGeneration++
snapshotFlow {
Triple(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
pagerState.currentPage
)
}.collectLatest {
kotlinx.coroutines.delay(EDGE_BLUR_RECAPTURE_DEBOUNCE_MS)
edgeBlurCaptureGeneration++
}
}

LaunchedEffect(listState.firstVisibleItemIndex, pagerState.currentPage, content.url) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
Expand Down Expand Up @@ -77,11 +78,11 @@ fun ReaderScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current

var showCloudflareWebView by remember { mutableStateOf(false) }
var cloudflareUrl by remember { mutableStateOf("") }
var showCloudflareWebView by rememberSaveable { mutableStateOf(false) }
var cloudflareUrl by rememberSaveable { mutableStateOf("") }

var showChapterList by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var showChapterList by rememberSaveable { mutableStateOf(false) }
var showSettings by rememberSaveable { mutableStateOf(false) }
val bottomSheetState = rememberModalBottomSheetState()
val settingsSheetState = rememberModalBottomSheetState()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.aatricks.easyreader.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.aatricks.easyreader.util.rethrowCancellation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -29,6 +30,7 @@ abstract class BaseViewModel<S>(initialState: S) : ViewModel() {
if (handleError) updateState { errorState(it, null) }

runCatching { block() }
.rethrowCancellation()
.onFailure { e ->
if (handleError) updateState { errorState(it, e.message ?: "An unknown error occurred") }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import android.util.Log
import io.aatricks.easyreader.util.rethrowCancellation

/**
* Owns the resolved-image-dimension pipeline extracted from ReaderViewModel.
Expand All @@ -32,6 +34,7 @@ class ImageDimensionManager(
private var contentDimApplyJob: Job? = null

companion object {
private const val TAG = "ImageDimensionManager"
private const val IMAGE_DIMENSION_FLUSH_DELAY_MS = 100L
private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L
}
Expand Down Expand Up @@ -78,9 +81,13 @@ class ImageDimensionManager(
val updates = pendingImageDimensions.toMap()
pendingImageDimensions.clear()
if (updates.isEmpty()) return
imageDimensionCache.persistAll(updates.map { (url, dimensions) ->
Triple(url, dimensions.first, dimensions.second)
})
runCatching {
imageDimensionCache.persistAll(updates.map { (url, dimensions) ->
Triple(url, dimensions.first, dimensions.second)
})
}.rethrowCancellation().onFailure { e ->
Log.e(TAG, "Failed to flush pending image dimensions", e)
}
}

// Matches the reader's resetState: drop the queued in-memory rebuild and the resolved map.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import io.aatricks.easyreader.work.ChapterDownloadQueue
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.Job
import io.aatricks.easyreader.util.rethrowCancellation
import javax.inject.Inject
import android.util.Log

Expand Down Expand Up @@ -347,6 +349,8 @@ class LibraryViewModel @Inject constructor(
return true
}

private var openNewChapterJob: Job? = null

private suspend fun addUnresolvedItem(url: String, contentType: ContentType) {
val fetchedTitle = runCatching { contentRepository.fetchTitle(url) }.getOrNull() ?: url
val fullTitle = fetchedTitle.trim().ifBlank { url }
Expand All @@ -368,7 +372,8 @@ class LibraryViewModel @Inject constructor(
sourceName: String,
onChapterLoaded: (String, String) -> Unit
): Unit {
viewModelScope.launch {
if (openNewChapterJob?.isActive == true) return
openNewChapterJob = viewModelScope.launch {
runCatching {
updateState { it.copy(isLoading = true) }
val details = exploreRepository.getNovelDetails(baseNovelUrl, sourceName)
Expand Down Expand Up @@ -401,7 +406,7 @@ class LibraryViewModel @Inject constructor(
repository.clearUpdateIndicator(item.id)
onChapterLoaded(item.url, item.id)
updateState { it.copy(isLoading = false) }
}.onFailure { e ->
}.rethrowCancellation().onFailure { e ->
Log.e(TAG, "Failed to open new chapter", e)
updateState { it.copy(isLoading = false, error = "Failed to load new chapter: ${e.message}") }
}
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.aatricks.easyreader.util

import kotlinx.coroutines.CancellationException

/**
* Rethrows kotlinx.coroutines.CancellationException if present in the Result.
*/
fun <T> Result<T>.rethrowCancellation(): Result<T> {
val e = exceptionOrNull()
if (e is CancellationException) throw e
return this
}
Loading
Loading