mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 21:52:25 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79fc3a0a8a | ||
|
|
c8b820a08f | ||
|
|
f45d0ad31d |
@@ -30,7 +30,7 @@ body:
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.21.2](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -52,7 +52,7 @@ body:
|
||||
label: Mihon version
|
||||
description: You can find your Mihon version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "0.21.2"
|
||||
Example: "0.21.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -95,7 +95,7 @@ body:
|
||||
required: true
|
||||
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.21.2](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
- label: I have updated the app to version **[0.21.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
required: true
|
||||
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
||||
required: true
|
||||
|
||||
+1
-57
@@ -11,60 +11,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
- `Other` - for technical stuff.
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388))
|
||||
- Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765))
|
||||
- Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801))
|
||||
- Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823))
|
||||
- Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843))
|
||||
- Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852))
|
||||
- Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870))
|
||||
- Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886))
|
||||
- Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872))
|
||||
- Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
|
||||
- Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
|
||||
- Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949))
|
||||
- Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956))
|
||||
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
|
||||
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
|
||||
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
|
||||
|
||||
### Changed
|
||||
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
|
||||
|
||||
### Improved
|
||||
- Create and restore backups faster and with less memory ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken), [@AntsyLich](https://github.com/AntsyLich))
|
||||
|
||||
### Removed
|
||||
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
|
||||
|
||||
## [v0.21.2] - 2026-10-13
|
||||
### Fixed
|
||||
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
|
||||
- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich)) ([`ea2b73a`](https://github.com/mihonapp/mihon/commit/ea2b73a))
|
||||
- Fixed a crash when a back gesture was still in progress as the reader or a dialog closed ([@AntsyLich](https://github.com/AntsyLich)) ([`43de88c`](https://github.com/mihonapp/mihon/commit/43de88c))
|
||||
- Fixed Kitsu login and other requests that send data failing with "Failed to bypass Cloudflare" ([@AntsyLich](https://github.com/AntsyLich)) ([`7ae858e`](https://github.com/mihonapp/mihon/commit/7ae858e))
|
||||
- Fixed tracker start date being off by a day when adding tracking to an entry with read history in some timezones ([@hazptr](https://github.com/hazptr)) ([#4049](https://github.com/mihonapp/mihon/pull/4049))
|
||||
- Fixed the paged reader crashing when moving to another chapter after a wide page of a neighbouring chapter was split ([@AntsyLich](https://github.com/AntsyLich)) ([`3156b4a`](https://github.com/mihonapp/mihon/commit/3156b4a))
|
||||
- Fixed a crash when opening the filters of a source with an invalid filter ([@AntsyLich](https://github.com/AntsyLich)) ([`c9e1b6e`](https://github.com/mihonapp/mihon/commit/c9e1b6e))
|
||||
- Fixed a crash when installing an extension on a device where the system installer can't ask for confirmation ([@AntsyLich](https://github.com/AntsyLich)) ([`2df48e1`](https://github.com/mihonapp/mihon/commit/2df48e1))
|
||||
- Fixed a crash when tapping in the long strip reader as it closed ([@AntsyLich](https://github.com/AntsyLich)) ([`c371d63`](https://github.com/mihonapp/mihon/commit/c371d63))
|
||||
- Fixed chapters downloaded before v0.21.0 showing as not downloaded when their name started with a space or dot, was long, or had a non-ASCII scanlator ([@AntsyLich](https://github.com/AntsyLich)) ([`f0f1e4d`](https://github.com/mihonapp/mihon/commit/f0f1e4d))
|
||||
- Fixed slowdowns and running out of memory when many requests to a site hit a Cloudflare challenge at once ([@tigeryu8900](https://github.com/tigeryu8900), [@AntsyLich](https://github.com/AntsyLich)) ([`5a5ff22`](https://github.com/mihonapp/mihon/commit/5a5ff22))
|
||||
|
||||
## [v0.21.1] - 2026-10-12
|
||||
### Fixed
|
||||
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich)) ([`8457fb9`](https://github.com/mihonapp/mihon/commit/8457fb9))
|
||||
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich)) ([`abb126a`](https://github.com/mihonapp/mihon/commit/abb126a))
|
||||
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich)) ([`c279291`](https://github.com/mihonapp/mihon/commit/c279291))
|
||||
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich)) ([`07b9fb6`](https://github.com/mihonapp/mihon/commit/07b9fb6))
|
||||
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich)) ([`6dffca1`](https://github.com/mihonapp/mihon/commit/6dffca1))
|
||||
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich)) ([`649fc83`](https://github.com/mihonapp/mihon/commit/649fc83))
|
||||
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich)) ([`ae749bb`](https://github.com/mihonapp/mihon/commit/ae749bb))
|
||||
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich)) ([`a999e49`](https://github.com/mihonapp/mihon/commit/a999e49))
|
||||
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich)) ([`9dbb714`](https://github.com/mihonapp/mihon/commit/9dbb714))
|
||||
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich)) ([`010ac71`](https://github.com/mihonapp/mihon/commit/010ac71))
|
||||
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich)) ([`51b61a3`](https://github.com/mihonapp/mihon/commit/51b61a3))
|
||||
|
||||
## [v0.21.0] - 2026-10-12
|
||||
### Added
|
||||
@@ -812,9 +758,7 @@ Same as v0.19.6
|
||||
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
|
||||
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
|
||||
|
||||
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.2...main
|
||||
[v0.21.2]: https://github.com/mihonapp/mihon/compare/v0.21.1...v0.21.2
|
||||
[v0.21.1]: https://github.com/mihonapp/mihon/compare/v0.21.0...v0.21.1
|
||||
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.0...main
|
||||
[v0.21.0]: https://github.com/mihonapp/mihon/compare/v0.20.4...v0.21.0
|
||||
[v0.20.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
|
||||
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
|
||||
|
||||
@@ -37,8 +37,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "app.mihon"
|
||||
|
||||
versionCode = 38
|
||||
versionName = "0.21.2"
|
||||
versionCode = 36
|
||||
versionName = "0.21.0"
|
||||
|
||||
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
|
||||
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
|
||||
@@ -229,7 +229,6 @@ dependencies {
|
||||
debugImplementation(libs.androidx.compose.uiTooling)
|
||||
implementation(libs.androidx.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.compose.uiUtil)
|
||||
implementation(libs.androidx.navigationEvent)
|
||||
|
||||
implementation(libs.androidx.interpolator)
|
||||
|
||||
@@ -292,8 +291,6 @@ dependencies {
|
||||
}
|
||||
implementation(libs.image.decoder)
|
||||
|
||||
implementation(libs.webgpuviewer)
|
||||
|
||||
// UI libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.flexibleAdapter)
|
||||
|
||||
@@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -37,7 +38,12 @@ class BasePreferences(
|
||||
PRIVATE(MR.strings.ext_installer_private, false),
|
||||
}
|
||||
|
||||
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false)
|
||||
val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
|
||||
|
||||
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
|
||||
"pref_hardware_bitmap_threshold",
|
||||
GLUtil.SAFE_TEXTURE_LIMIT,
|
||||
)
|
||||
|
||||
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
|
||||
import eu.kanade.tachiyomi.data.track.Tracker
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
@@ -59,7 +61,10 @@ class AddTracks(
|
||||
.minOrNull()
|
||||
|
||||
firstReadChapterDate?.let {
|
||||
val startDate = firstReadChapterDate.toEpochMilliseconds()
|
||||
val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
|
||||
TimeZone.currentSystemDefault(),
|
||||
TimeZone.UTC,
|
||||
)
|
||||
track = track.copy(
|
||||
startDate = Instant.fromEpochMilliseconds(startDate),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -536,20 +535,20 @@ fun MangaScreenLargeImpl(
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Aligned by its own modifier: a wrapper would always be laid out, and the scaffold then drops the
|
||||
// navigation bar inset under the FAB
|
||||
SharedMangaBottomActionMenu(
|
||||
selected = state.selectedChapters,
|
||||
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||||
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||||
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||||
onDownloadChapter = onDownloadChapter,
|
||||
onMultiDeleteClicked = onMultiDeleteClicked,
|
||||
fillFraction = 0.5f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.End),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
) {
|
||||
SharedMangaBottomActionMenu(
|
||||
selected = state.selectedChapters,
|
||||
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||||
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||||
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||||
onDownloadChapter = onDownloadChapter,
|
||||
onMultiDeleteClicked = onMultiDeleteClicked,
|
||||
fillFraction = 0.5f,
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -26,9 +25,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -36,27 +33,16 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.updatePadding
|
||||
import ca.mpreg.webgpuviewer.renderer.GainmapInput
|
||||
import ca.mpreg.webgpuviewer.renderer.Image
|
||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
||||
import ca.mpreg.webgpuviewer.viewer.ImageViewer
|
||||
import ca.mpreg.webgpuviewer.viewer.ImageViewerState
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.size.Size
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.presentation.manga.EditCoverAction
|
||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||
import eu.kanade.tachiyomi.data.coil.newDecoder
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.app.di.appGraph
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.rounded.Close
|
||||
import mihon.icons.materialsymbols.rounded.Edit
|
||||
@@ -79,9 +65,6 @@ fun MangaCoverDialog(
|
||||
onEditClick: ((EditCoverAction) -> Unit)?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val useNewRenderer = LocalContext.current.appGraph.basePreferences.highQualityRenderer.get()
|
||||
val view = LocalView.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(
|
||||
@@ -168,59 +151,6 @@ fun MangaCoverDialog(
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
if (useNewRenderer) {
|
||||
val state = remember(manga) { ImageViewerState() }
|
||||
LaunchedEffect(manga) {
|
||||
val request = ImageRequest.Builder(view.context)
|
||||
.data(manga)
|
||||
.size(Size.ORIGINAL)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.newDecoder(true)
|
||||
.build()
|
||||
val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect
|
||||
val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect
|
||||
// Held, then freed, around the upload: its buffer alone doesn't keep
|
||||
// the frame's native pixels alive.
|
||||
val page = res.frame.use {
|
||||
withContext(Dispatchers.Default) {
|
||||
ImagePage.ImageSingle(
|
||||
Image(
|
||||
res.image,
|
||||
res.width,
|
||||
res.height,
|
||||
createMipMaps = true,
|
||||
backgroundColor = 0,
|
||||
hdr = res.isHdr,
|
||||
hdrHeadroom = res.hdrHeadroom,
|
||||
gainmap = res.gainmap?.let {
|
||||
GainmapInput(
|
||||
pixels = it.pixels,
|
||||
width = it.width,
|
||||
height = it.height,
|
||||
channels = it.channels,
|
||||
gamma = it.gamma,
|
||||
minContentBoost = it.minContentBoost,
|
||||
maxContentBoost = it.maxContentBoost,
|
||||
offsetSdr = it.offsetSdr,
|
||||
offsetHdr = it.offsetHdr,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
state.apply {
|
||||
fetchPage = { index ->
|
||||
if (index == 0) page else null
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
ImageViewer(state = state)
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
|
||||
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
|
||||
|
||||
|
||||
+38
-3
@@ -6,6 +6,8 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
|
||||
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
|
||||
import eu.kanade.tachiyomi.ui.more.OnboardingScreen
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
|
||||
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
|
||||
import eu.kanade.tachiyomi.util.system.powerManager
|
||||
@@ -53,6 +56,7 @@ import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import mihon.app.di.appGraph
|
||||
import okhttp3.Headers
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -333,12 +337,43 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
basePreferences: BasePreferences,
|
||||
): Preference.PreferenceGroup {
|
||||
val context = LocalContext.current
|
||||
val chooseColorProfile = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
basePreferences.displayProfile.set(uri.toString())
|
||||
}
|
||||
}
|
||||
return Preference.PreferenceGroup(
|
||||
title = stringResource(MR.strings.pref_category_reader),
|
||||
preferenceItems = listOf(
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
preference = basePreferences.highQualityRenderer,
|
||||
title = stringResource(MR.strings.pref_high_quality_renderer),
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
preference = basePreferences.hardwareBitmapThreshold,
|
||||
entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS
|
||||
.mapIndexed { index, option ->
|
||||
val display = if (index == 0) {
|
||||
stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option)
|
||||
} else {
|
||||
option.toString()
|
||||
}
|
||||
option to display
|
||||
}
|
||||
.toMap(),
|
||||
title = stringResource(MR.strings.pref_hardware_bitmap_threshold),
|
||||
subtitleProvider = { value, options ->
|
||||
stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty())
|
||||
},
|
||||
visible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
|
||||
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT,
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.pref_display_profile),
|
||||
subtitle = basePreferences.displayProfile.get(),
|
||||
onClick = {
|
||||
chooseColorProfile.launch(arrayOf("*/*"))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
import mihon.app.di.appGraph
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -31,7 +30,6 @@ import java.text.NumberFormat
|
||||
internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
||||
HeadingItem(MR.strings.pref_category_for_this_series)
|
||||
val manga by viewModel.mangaFlow.collectAsState()
|
||||
val viewer by viewModel.viewerFlow.collectAsState()
|
||||
|
||||
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
||||
SettingsChipRow(MR.strings.pref_category_reading_mode) {
|
||||
@@ -44,62 +42,6 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (viewer is WebGpuViewer) {
|
||||
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
|
||||
val resolved = ReadingMode.fromPreference(
|
||||
when {
|
||||
readingMode == ReadingMode.DEFAULT -> default
|
||||
else -> manga?.readingMode?.toInt() ?: default
|
||||
},
|
||||
)
|
||||
if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) {
|
||||
val dualPageView by viewModel.preferences.dualPageView.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_dual_page_view) {
|
||||
ReaderPreferences.DualPageView.entries.map {
|
||||
FilterChip(
|
||||
selected = it == dualPageView,
|
||||
onClick = { viewModel.preferences.dualPageView.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
||||
val numberFormat = remember { NumberFormat.getPercentInstance() }
|
||||
val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState()
|
||||
SliderItem(
|
||||
value = continuousMinWidth,
|
||||
valueRange = ReaderPreferences.let { 1..100 },
|
||||
label = stringResource(MR.strings.pref_continuous_minwidth),
|
||||
valueString = numberFormat.format(continuousMinWidth / 100f),
|
||||
onChange = {
|
||||
viewModel.preferences.continuousMinWidth.set(it)
|
||||
},
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
if (resolved == ReadingMode.CONTINUOUS_VERTICAL) {
|
||||
val continuousGap by viewModel.preferences.continuousGap.collectAsState()
|
||||
SliderItem(
|
||||
value = continuousGap,
|
||||
valueRange = ReaderPreferences.let { 1..100 },
|
||||
label = stringResource(MR.strings.pref_continuous_gap),
|
||||
valueString = numberFormat.format(continuousGap / 100f),
|
||||
onChange = {
|
||||
viewModel.preferences.continuousGap.set(it)
|
||||
},
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
}
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_webtoon_disable_zoom_out),
|
||||
pref = viewModel.preferences.webtoonDisableZoomOut,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) }
|
||||
SettingsChipRow(MR.strings.rotation_type) {
|
||||
ReaderOrientation.entries.map {
|
||||
@@ -111,10 +53,11 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
when (viewer) {
|
||||
is WebtoonViewer -> WebtoonViewerSettings(viewModel)
|
||||
is WebGpuViewer -> WebGpuViewerSettings(viewModel)
|
||||
else -> PagerViewerSettings(viewModel)
|
||||
val viewer by viewModel.viewerFlow.collectAsState()
|
||||
if (viewer is WebtoonViewer) {
|
||||
WebtoonViewerSettings(viewModel)
|
||||
} else {
|
||||
PagerViewerSettings(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,120 +235,3 @@ private fun ColumnScope.TapZonesItems(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) {
|
||||
HeadingItem(MR.strings.webgpu_viewer)
|
||||
|
||||
val manga by viewModel.mangaFlow.collectAsState()
|
||||
val viewer by viewModel.viewerFlow.collectAsState()
|
||||
|
||||
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
|
||||
val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get()
|
||||
val resolved = ReadingMode.fromPreference(
|
||||
when {
|
||||
readingMode == ReadingMode.DEFAULT -> default
|
||||
else -> manga?.readingMode?.toInt() ?: default
|
||||
},
|
||||
)
|
||||
val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true
|
||||
|
||||
val navigationModePager by viewModel.preferences.navigationModePager.collectAsState()
|
||||
val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState()
|
||||
TapZonesItems(
|
||||
selected = navigationModePager,
|
||||
onSelect = viewModel.preferences.navigationModePager::set,
|
||||
invertMode = pagerNavInverted,
|
||||
onSelectInvertMode = viewModel.preferences.pagerNavInverted::set,
|
||||
)
|
||||
|
||||
if (isDual) {
|
||||
val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_transition_animation_dual) {
|
||||
(
|
||||
ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT -
|
||||
ReaderPreferences.TransitionAnimation.FLIP_RIGHT
|
||||
).forEach {
|
||||
FilterChip(
|
||||
selected = it == transitionAnimation,
|
||||
onClick = { viewModel.preferences.transitionAnimationDual.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_cutout_mode_dual) {
|
||||
ReaderPreferences.CutoutMode.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == cutoutMode,
|
||||
onClick = { viewModel.preferences.cutoutModeDual.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) {
|
||||
val imageScaleType by viewModel.preferences.imageScaleType.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_image_scale_type) {
|
||||
ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach {
|
||||
FilterChip(
|
||||
selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it,
|
||||
onClick = {
|
||||
viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1)
|
||||
},
|
||||
label = { Text(stringResource(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val zoomStart by viewModel.preferences.zoomStart.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_zoom_start) {
|
||||
ReaderPreferences.ZoomStart.mapIndexed { index, it ->
|
||||
FilterChip(
|
||||
selected = zoomStart == index + 1,
|
||||
onClick = { viewModel.preferences.zoomStart.set(index + 1) },
|
||||
label = { Text(stringResource(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_crop_borders),
|
||||
pref = viewModel.preferences.cropBorders,
|
||||
)
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_landscape_zoom),
|
||||
pref = viewModel.preferences.landscapeZoom,
|
||||
)
|
||||
|
||||
CheckboxItem(
|
||||
label = stringResource(MR.strings.pref_navigate_pan),
|
||||
pref = viewModel.preferences.navigateToPan,
|
||||
)
|
||||
|
||||
val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_transition_animation) {
|
||||
ReaderPreferences.TransitionAnimation.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == transitionAnimation,
|
||||
onClick = { viewModel.preferences.transitionAnimation.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val cutoutMode by viewModel.preferences.cutoutMode.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_cutout_mode) {
|
||||
ReaderPreferences.CutoutMode.entries.forEach {
|
||||
FilterChip(
|
||||
selected = it == cutoutMode,
|
||||
onClick = { viewModel.preferences.cutoutMode.set(it) },
|
||||
label = { Text(stringResource(it.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ import android.graphics.Bitmap
|
||||
import android.os.Message
|
||||
import android.webkit.JsPromptResult
|
||||
import android.webkit.JsResult
|
||||
import android.webkit.RenderProcessGoneDetail
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -47,15 +45,12 @@ import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.util.system.getHtml
|
||||
import eu.kanade.tachiyomi.util.system.setDefaultSettings
|
||||
import eu.kanade.tachiyomi.util.system.setUserAgent
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.automirroredrounded.ArrowBack
|
||||
import mihon.icons.materialsymbols.automirroredrounded.ArrowForward
|
||||
import mihon.icons.materialsymbols.rounded.Close
|
||||
import mihon.icons.materialsymbols.rounded.TabClose
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
@@ -157,17 +152,6 @@ fun WebViewScreenContent(
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||||
// Not handling it kills the app along with the page
|
||||
logcat(LogPriority.ERROR) {
|
||||
"WebView renderer gone: crashed=${detail.didCrash()}, " +
|
||||
"priority=${detail.rendererPriorityAtExit()}, url=$currentUrl"
|
||||
}
|
||||
view.context.toast(MR.strings.information_webview_crashed, Toast.LENGTH_LONG)
|
||||
onNavigateUp()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
|
||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.system.cancelNotification
|
||||
@@ -68,6 +69,7 @@ import org.conscrypt.Conscrypt
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -184,6 +186,14 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
||||
.onEach(TelemetryConfig::setCrashlyticsEnabled)
|
||||
.launchIn(scope)
|
||||
|
||||
basePreferences.hardwareBitmapThreshold.let { preference ->
|
||||
if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT)
|
||||
}
|
||||
|
||||
basePreferences.hardwareBitmapThreshold.changes()
|
||||
.onEach { ImageUtil.hardwareBitmapThreshold = it }
|
||||
.launchIn(scope)
|
||||
|
||||
setAppCompatDelegateThemeMode(uiPreferences.themeMode.get())
|
||||
|
||||
// Updates widget update
|
||||
|
||||
@@ -4,11 +4,8 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import okio.BufferedSource
|
||||
import okio.buffer
|
||||
import okio.gzip
|
||||
import okio.source
|
||||
@@ -22,38 +19,9 @@ class BackupDecoder(
|
||||
private val parser: ProtoBuf,
|
||||
) {
|
||||
/**
|
||||
* Decode everything but the entries of a potentially-gzipped backup, counting the entries without decoding them.
|
||||
* Decode a potentially-gzipped backup.
|
||||
*/
|
||||
fun decodeMetadata(uri: Uri): Metadata {
|
||||
return read(uri) { reader ->
|
||||
orInvalid {
|
||||
var entryCount = 0
|
||||
while (reader.nextEntry()) {
|
||||
reader.skipEntry()
|
||||
entryCount++
|
||||
}
|
||||
Metadata(entryCount, reader.readMetadata())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the entries of a potentially-gzipped backup one at a time, skipping those whose index [include] rejects.
|
||||
*/
|
||||
fun decodeEntries(uri: Uri, include: (index: Int) -> Boolean = { true }): Flow<BackupManga> = flow {
|
||||
read(uri) { reader ->
|
||||
var index = 0
|
||||
while (orInvalid { reader.nextEntry() }) {
|
||||
if (include(index++)) {
|
||||
emit(orInvalid { reader.readEntry() })
|
||||
} else {
|
||||
orInvalid { reader.skipEntry() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> read(uri: Uri, block: (BackupReader) -> T): T {
|
||||
fun decode(uri: Uri): Backup {
|
||||
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
|
||||
val source = inputStream.source().buffer()
|
||||
|
||||
@@ -61,32 +29,22 @@ class BackupDecoder(
|
||||
require(2)
|
||||
}
|
||||
val id1id2 = peeked.readShort()
|
||||
val backupSource: BufferedSource = when (id1id2.toInt()) {
|
||||
val backupString = when (id1id2.toInt()) {
|
||||
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
|
||||
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
|
||||
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
|
||||
}
|
||||
else -> source
|
||||
}.use { it.readByteArray() }
|
||||
|
||||
try {
|
||||
parser.decodeFromByteArray(Backup.serializer(), backupString)
|
||||
} catch (_: SerializationException) {
|
||||
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
|
||||
}
|
||||
|
||||
backupSource.use { block(BackupReader(it, parser)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Only around reading, so the JSON error above and anything thrown downstream of emit keep their own messages
|
||||
private inline fun <T> orInvalid(block: () -> T): T {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: IOException) {
|
||||
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown), e)
|
||||
}
|
||||
}
|
||||
|
||||
data class Metadata(
|
||||
val entryCount: Int,
|
||||
val backup: Backup,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MAGIC_JSON_SIGNATURE1 = 0x7b7d // `{}`
|
||||
private const val MAGIC_JSON_SIGNATURE2 = 0x7b22 // `{"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.backup
|
||||
import android.net.Uri
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
|
||||
@Inject
|
||||
@@ -19,14 +18,8 @@ class BackupFileValidator(
|
||||
* @return List of missing sources or missing trackers.
|
||||
*/
|
||||
suspend fun validate(uri: Uri): Results {
|
||||
val trackerIds = mutableSetOf<Int>()
|
||||
val backup = try {
|
||||
backupDecoder.decodeEntries(uri).collect { manga ->
|
||||
manga.tracking.mapTo(trackerIds) { it.syncId }
|
||||
}
|
||||
backupDecoder.decodeMetadata(uri).backup
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
backupDecoder.decode(uri)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException(e)
|
||||
}
|
||||
@@ -45,7 +38,11 @@ class BackupFileValidator(
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
val missingTrackers = trackerIds
|
||||
val trackers = backup.backupManga
|
||||
.flatMap { it.tracking }
|
||||
.map { it.syncId }
|
||||
.distinct()
|
||||
val missingTrackers = trackers
|
||||
.mapNotNull { trackerManager.get(it.toLong()) }
|
||||
.filter { !it.isLoggedIn }
|
||||
.map { it.name }
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import okio.Buffer
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Writes the same bytes as encoding a whole [Backup], without holding all of its entries at once.
|
||||
*/
|
||||
class BackupWriter(
|
||||
private val sink: BufferedSink,
|
||||
private val parser: ProtoBuf,
|
||||
) {
|
||||
var isEmpty = true
|
||||
private set
|
||||
|
||||
fun writeEntry(entry: BackupManga) {
|
||||
val bytes = parser.encodeToByteArray(BackupManga.serializer(), entry)
|
||||
sink.writeVarint(ENTRY_TAG)
|
||||
sink.writeVarint(bytes.size.toLong())
|
||||
sink.write(bytes)
|
||||
isEmpty = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the other fields after the entries, as [metadata] without entries of its own.
|
||||
*/
|
||||
fun writeMetadata(metadata: Backup) {
|
||||
val bytes = parser.encodeToByteArray(Backup.serializer(), metadata)
|
||||
sink.write(bytes)
|
||||
if (bytes.isNotEmpty()) isEmpty = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an encoded [Backup] one entry at a time. Every other field is set aside for [readMetadata], wherever it
|
||||
* appears.
|
||||
*
|
||||
* Malformed or truncated input throws an [IOException].
|
||||
*/
|
||||
class BackupReader(
|
||||
private val source: BufferedSource,
|
||||
private val parser: ProtoBuf,
|
||||
) {
|
||||
private val metadata = Buffer()
|
||||
private var entryLength = NO_ENTRY
|
||||
|
||||
/**
|
||||
* Moves to the next entry, which [readEntry] or [skipEntry] then consumes.
|
||||
*
|
||||
* @return false once the input is exhausted.
|
||||
*/
|
||||
fun nextEntry(): Boolean {
|
||||
check(entryLength == NO_ENTRY)
|
||||
while (!source.exhausted()) {
|
||||
val tag = source.readVarint()
|
||||
val fieldNumber = tag ushr 3
|
||||
val wireType = (tag and 0x7).toInt()
|
||||
if (fieldNumber == 0L) throw IOException("Invalid field number 0")
|
||||
|
||||
if (tag == ENTRY_TAG) {
|
||||
entryLength = source.readLength()
|
||||
return true
|
||||
}
|
||||
|
||||
metadata.writeVarint(tag)
|
||||
when (wireType) {
|
||||
WIRE_TYPE_VARINT -> metadata.writeVarint(source.readVarint())
|
||||
WIRE_TYPE_FIXED64 -> metadata.write(source, 8)
|
||||
WIRE_TYPE_LENGTH_DELIMITED -> {
|
||||
val length = source.readLength()
|
||||
metadata.writeVarint(length)
|
||||
metadata.write(source, length)
|
||||
}
|
||||
WIRE_TYPE_FIXED32 -> metadata.write(source, 4)
|
||||
else -> throw IOException("Unsupported wire type $wireType")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun readEntry(): BackupManga {
|
||||
val bytes = source.readByteArray(takeEntryLength())
|
||||
return decode(BackupManga.serializer(), bytes)
|
||||
}
|
||||
|
||||
fun skipEntry() {
|
||||
source.skip(takeEntryLength())
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips the remaining entries and decodes everything else as a [Backup] without entries.
|
||||
*/
|
||||
fun readMetadata(): Backup {
|
||||
if (entryLength != NO_ENTRY) skipEntry()
|
||||
while (nextEntry()) skipEntry()
|
||||
return decode(Backup.serializer(), metadata.readByteArray())
|
||||
}
|
||||
|
||||
private fun takeEntryLength(): Long {
|
||||
check(entryLength != NO_ENTRY)
|
||||
return entryLength.also { entryLength = NO_ENTRY }
|
||||
}
|
||||
|
||||
private fun <T> decode(deserializer: DeserializationStrategy<T>, bytes: ByteArray): T {
|
||||
return try {
|
||||
parser.decodeFromByteArray(deserializer, bytes)
|
||||
} catch (e: SerializationException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BufferedSource.readVarint(): Long {
|
||||
var result = 0L
|
||||
for (shift in 0 until Long.SIZE_BITS step 7) {
|
||||
val byte = readByte().toInt()
|
||||
result = result or ((byte and 0x7F).toLong() shl shift)
|
||||
if ((byte and 0x80) == 0) return result
|
||||
}
|
||||
throw IOException("Malformed varint")
|
||||
}
|
||||
|
||||
private fun BufferedSource.readLength(): Long {
|
||||
val length = readVarint()
|
||||
if (length !in 0..Int.MAX_VALUE) throw IOException("Invalid length $length")
|
||||
return length
|
||||
}
|
||||
|
||||
private fun BufferedSink.writeVarint(value: Long) {
|
||||
var remaining = value
|
||||
while ((remaining and 0x7FL.inv()) != 0L) {
|
||||
writeByte((remaining and 0x7F).toInt() or 0x80)
|
||||
remaining = remaining ushr 7
|
||||
}
|
||||
writeByte(remaining.toInt())
|
||||
}
|
||||
|
||||
private const val WIRE_TYPE_VARINT = 0
|
||||
private const val WIRE_TYPE_FIXED64 = 1
|
||||
private const val WIRE_TYPE_LENGTH_DELIMITED = 2
|
||||
private const val WIRE_TYPE_FIXED32 = 5
|
||||
|
||||
// Backup.backupManga is field 1, and each entry is length-delimited
|
||||
private const val ENTRY_TAG = 1L shl 3 or WIRE_TYPE_LENGTH_DELIMITED.toLong()
|
||||
|
||||
private const val NO_ENTRY = -1L
|
||||
@@ -8,7 +8,6 @@ import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
|
||||
import eu.kanade.tachiyomi.data.backup.BackupWriter
|
||||
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
|
||||
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
|
||||
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
|
||||
@@ -21,8 +20,6 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import logcat.LogPriority
|
||||
import okio.buffer
|
||||
@@ -88,31 +85,27 @@ class BackupCreator(
|
||||
val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
|
||||
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options)
|
||||
|
||||
val backup = Backup(
|
||||
backupManga = backupManga,
|
||||
backupCategories = backupCategories(options),
|
||||
backupSources = backupSources(backupManga),
|
||||
backupPreferences = backupAppPreferences(options),
|
||||
backupExtensionStores = backupExtensionStores(options),
|
||||
backupSourcePreferences = backupSourcePreferences(options),
|
||||
)
|
||||
|
||||
val byteArray = parser.encodeToByteArray(Backup.serializer(), backup)
|
||||
if (byteArray.isEmpty()) {
|
||||
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
||||
}
|
||||
|
||||
file.openOutputStream()
|
||||
.also {
|
||||
// Force overwrite old file
|
||||
(it as? FileOutputStream)?.channel?.truncate(0)
|
||||
}
|
||||
.sink().gzip().buffer().use {
|
||||
val writer = BackupWriter(it, parser)
|
||||
val sourceIds = mutableSetOf<Long>()
|
||||
backupManga.collect { manga ->
|
||||
writer.writeEntry(manga)
|
||||
sourceIds += manga.source
|
||||
}
|
||||
writer.writeMetadata(
|
||||
Backup(
|
||||
backupManga = emptyList(),
|
||||
backupCategories = backupCategories(options),
|
||||
backupSources = backupSources(sourceIds),
|
||||
backupPreferences = backupAppPreferences(options),
|
||||
backupExtensionStores = backupExtensionStores(options),
|
||||
backupSourcePreferences = backupSourcePreferences(options),
|
||||
),
|
||||
)
|
||||
if (writer.isEmpty) {
|
||||
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
||||
}
|
||||
it.write(byteArray)
|
||||
}
|
||||
val fileUri = file.uri
|
||||
|
||||
@@ -137,14 +130,14 @@ class BackupCreator(
|
||||
return categoriesBackupCreator()
|
||||
}
|
||||
|
||||
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
|
||||
if (!options.libraryEntries) return emptyFlow()
|
||||
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
|
||||
if (!options.libraryEntries) return emptyList()
|
||||
|
||||
return mangaBackupCreator(mangas, options)
|
||||
}
|
||||
|
||||
private suspend fun backupSources(sourceIds: Set<Long>): List<BackupSource> {
|
||||
return sourcesBackupCreator(sourceIds)
|
||||
private suspend fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
|
||||
return sourcesBackupCreator(mangas)
|
||||
}
|
||||
|
||||
private fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
||||
|
||||
+39
-50
@@ -7,13 +7,11 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import mihon.core.common.extensions.toByteArray
|
||||
import mihon.core.common.extensions.toEpochMillisOrZero
|
||||
import tachiyomi.domain.category.repository.CategoryRepository
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import tachiyomi.domain.history.repository.HistoryRepository
|
||||
import tachiyomi.domain.history.interactor.GetHistory
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.track.repository.TrackRepository
|
||||
@@ -22,71 +20,62 @@ import tachiyomi.domain.track.repository.TrackRepository
|
||||
class MangaBackupCreator(
|
||||
private val mangaRepository: MangaRepository,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val trackRepository: TrackRepository,
|
||||
private val getCategories: GetCategories,
|
||||
private val getHistory: GetHistory,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Builds the entries a chunk at a time, so only one chunk's chapters are held at once.
|
||||
*/
|
||||
operator fun invoke(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> = flow {
|
||||
mangas.chunked(CHUNK_SIZE).forEach { chunk ->
|
||||
backupMangas(chunk, options).forEach { emit(it) }
|
||||
suspend operator fun invoke(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
|
||||
return mangas.map {
|
||||
backupManga(it, options)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
|
||||
val mangaIds = mangas.map { it.id }
|
||||
private suspend fun backupManga(manga: Manga, options: BackupOptions): BackupManga {
|
||||
// Entry for this manga
|
||||
val mangaObject = manga.toBackupManga()
|
||||
|
||||
val excludedScanlatorsByMangaId = mangaRepository.getExcludedScanlatorsByMangaIds(mangaIds)
|
||||
// History is backed up by chapter URL, so it needs the chapters too
|
||||
val chaptersByMangaId = if (options.chapters || options.history) {
|
||||
chapterRepository.getChaptersByMangaIds(mangaIds)
|
||||
} else {
|
||||
emptyMap()
|
||||
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
|
||||
|
||||
if (options.chapters) {
|
||||
// Backup all the chapters
|
||||
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
|
||||
.map { it.toBackupChapter() }
|
||||
.takeUnless { it.isEmpty() }
|
||||
?.let { mangaObject.chapters = it }
|
||||
}
|
||||
val categoriesByMangaId = if (options.categories) {
|
||||
categoryRepository.getCategoriesByMangaIds(mangaIds)
|
||||
} else {
|
||||
emptyMap()
|
||||
|
||||
if (options.categories) {
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = getCategories.await(manga.id)
|
||||
if (categoriesForManga.isNotEmpty()) {
|
||||
mangaObject.categories = categoriesForManga.map { it.order }
|
||||
}
|
||||
}
|
||||
val tracksByMangaId = if (options.tracking) trackRepository.getTracksByMangaIds(mangaIds) else emptyMap()
|
||||
val historyByMangaId = if (options.history) historyRepository.getHistoryByMangaIds(mangaIds) else emptyMap()
|
||||
|
||||
return mangas.map { manga ->
|
||||
val mangaObject = manga.toBackupManga()
|
||||
val chapters = chaptersByMangaId[manga.id].orEmpty()
|
||||
|
||||
mangaObject.excludedScanlators = excludedScanlatorsByMangaId[manga.id].orEmpty()
|
||||
|
||||
if (options.chapters) {
|
||||
mangaObject.chapters = chapters.map { it.toBackupChapter() }
|
||||
if (options.tracking) {
|
||||
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
|
||||
if (tracks.isNotEmpty()) {
|
||||
mangaObject.tracking = tracks
|
||||
}
|
||||
}
|
||||
|
||||
if (options.categories) {
|
||||
mangaObject.categories = categoriesByMangaId[manga.id].orEmpty().map { it.order }
|
||||
}
|
||||
|
||||
if (options.tracking) {
|
||||
mangaObject.tracking = tracksByMangaId[manga.id].orEmpty().map { it.toBackupTracking() }
|
||||
}
|
||||
|
||||
if (options.history) {
|
||||
val chapterUrlsById = chapters.associate { it.id to it.url }
|
||||
mangaObject.history = historyByMangaId[manga.id].orEmpty().mapNotNull { history ->
|
||||
if (options.history) {
|
||||
val historyByMangaId = getHistory.await(manga.id)
|
||||
if (historyByMangaId.isNotEmpty()) {
|
||||
val chapterUrlsById = chapterRepository.getChapterByMangaId(manga.id).associate { it.id to it.url }
|
||||
val history = historyByMangaId.mapNotNull { history ->
|
||||
// A chapter removed since its history was read takes that history with it
|
||||
val url = chapterUrlsById[history.chapterId] ?: return@mapNotNull null
|
||||
BackupHistory(url, history.readAt.toEpochMillisOrZero(), history.readDuration)
|
||||
}
|
||||
if (history.isNotEmpty()) {
|
||||
mangaObject.history = history
|
||||
}
|
||||
}
|
||||
|
||||
mangaObject
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_SIZE = 100
|
||||
return mangaObject
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.backup.create.creators
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
@@ -10,8 +11,11 @@ class SourcesBackupCreator(
|
||||
private val sourceManager: SourceManager,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(sourceIds: Set<Long>): List<BackupSource> {
|
||||
return sourceIds.map { sourceManager.getOrStub(it).toBackupSource() }
|
||||
suspend operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
|
||||
return mangas
|
||||
.map(BackupManga::source)
|
||||
.distinct()
|
||||
.map { sourceManager.getOrStub(it).toBackupSource() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.backup.BackupDecoder
|
||||
import eu.kanade.tachiyomi.data.backup.BackupNotifier
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
||||
@@ -22,12 +23,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.chunked
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
@@ -37,7 +32,6 @@ import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.BitSet
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
@@ -103,7 +97,7 @@ class BackupRestorer(
|
||||
}
|
||||
|
||||
private suspend fun restoreFromFile(uri: Uri, options: RestoreOptions) {
|
||||
val (entryCount, backup) = backupDecoder.decodeMetadata(uri)
|
||||
val backup = backupDecoder.decode(uri)
|
||||
|
||||
// Store source mapping for error messages
|
||||
val backupMaps = backup.backupSources
|
||||
@@ -111,7 +105,7 @@ class BackupRestorer(
|
||||
|
||||
if (options.libraryEntries) {
|
||||
restoreSourceNames(backupMaps)
|
||||
restoreAmount += entryCount
|
||||
restoreAmount += backup.backupManga.size
|
||||
}
|
||||
if (options.categories) {
|
||||
restoreAmount += 1
|
||||
@@ -144,7 +138,7 @@ class BackupRestorer(
|
||||
}
|
||||
if (options.libraryEntries) {
|
||||
restoreManga(
|
||||
uri,
|
||||
backup.backupManga,
|
||||
if (options.categories) backup.backupCategories else emptyList(),
|
||||
restoreCategoriesJob,
|
||||
)
|
||||
@@ -181,31 +175,14 @@ class BackupRestorer(
|
||||
}
|
||||
|
||||
private fun CoroutineScope.restoreManga(
|
||||
uri: Uri,
|
||||
backupMangas: List<BackupManga>,
|
||||
backupCategories: List<BackupCategory>,
|
||||
categoriesRestoreJob: Job?,
|
||||
) = launch {
|
||||
categoriesRestoreJob?.join()
|
||||
|
||||
// New entries go first. Rather than hold the others back in memory, a second pass reads them again.
|
||||
val isExisting = mangaRestorer.getExistingMatcher()
|
||||
val existingIndices = BitSet()
|
||||
val newMangas = backupDecoder.decodeEntries(uri)
|
||||
.withIndex()
|
||||
.filter { (index, manga) ->
|
||||
val exists = isExisting(manga)
|
||||
if (exists) existingIndices.set(index)
|
||||
!exists
|
||||
}
|
||||
.map { it.value }
|
||||
val backupMangas = flow {
|
||||
emitAll(newMangas)
|
||||
emitAll(backupDecoder.decodeEntries(uri, existingIndices::get))
|
||||
}
|
||||
|
||||
backupMangas
|
||||
mangaRestorer.sortByNew(backupMangas)
|
||||
.chunked(100)
|
||||
.collect { chunk ->
|
||||
.forEach { chunk ->
|
||||
val restoredAsBatch = try {
|
||||
ensureActive()
|
||||
mangaRestorer.restore(chunk, backupCategories)
|
||||
|
||||
+3
-5
@@ -23,13 +23,11 @@ class MangaRestorer(
|
||||
private val now = Clock.System.now().toLocalDateTime(timeZone)
|
||||
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
|
||||
|
||||
/**
|
||||
* Returns whether an entry was already in the database at the time of this call.
|
||||
*/
|
||||
suspend fun getExistingMatcher(): (BackupManga) -> Boolean {
|
||||
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
|
||||
val urlsBySource = restoreRepository.getMangaUrlsBySourceId()
|
||||
|
||||
return { it.url in urlsBySource[it.source].orEmpty() }
|
||||
return backupMangas
|
||||
.sortedBy { it.url in urlsBySource[it.source].orEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,6 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.DownloadStream
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -151,11 +150,10 @@ class ChapterCache(
|
||||
*
|
||||
* @param imageUrl url of image.
|
||||
* @param response http response from page.
|
||||
* @param live also receives the bytes as they arrive.
|
||||
* @throws IOException image error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun putImageToCache(imageUrl: String, response: Response, live: DownloadStream? = null) {
|
||||
fun putImageToCache(imageUrl: String, response: Response) {
|
||||
// Initialize editor (edits the values for an entry).
|
||||
var editor: DiskLruCache.Editor? = null
|
||||
|
||||
@@ -165,22 +163,7 @@ class ChapterCache(
|
||||
editor = diskCache.edit(key) ?: return
|
||||
|
||||
// Get OutputStream and write image with Okio.
|
||||
if (live == null) {
|
||||
response.body.source().saveTo(editor.newOutputStream(0))
|
||||
} else {
|
||||
live.reserve(response.body.contentLength())
|
||||
response.body.source().use { source ->
|
||||
editor.newOutputStream(0).use { out ->
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val n = source.read(buffer)
|
||||
if (n < 0) break
|
||||
out.write(buffer, 0, n)
|
||||
live.append(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response.body.source().saveTo(editor.newOutputStream(0))
|
||||
|
||||
diskCache.flush()
|
||||
editor.commit()
|
||||
|
||||
@@ -2,9 +2,6 @@ package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import ca.mpreg.imagedecoder.ImageDecoder
|
||||
import coil3.Canvas
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
@@ -15,39 +12,20 @@ import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.bitmapConfig
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* A [Decoder] that uses [ImageDecoder] to decode image formats not supported
|
||||
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
|
||||
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
|
||||
*/
|
||||
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
|
||||
|
||||
/**
|
||||
* Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want
|
||||
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
|
||||
*/
|
||||
class DecodeResultImage(
|
||||
val frame: ImageDecoder.Frame,
|
||||
val isHdr: Boolean,
|
||||
val hdrHeadroom: Float,
|
||||
val gainmap: ImageDecoder.Gainmap?,
|
||||
) : Image {
|
||||
val image: java.nio.ByteBuffer get() = frame.image
|
||||
|
||||
// Taken now: a caller may close the frame once it has the pixels.
|
||||
override val size: Long = frame.image.capacity().toLong()
|
||||
override val width: Int get() = frame.width
|
||||
override val height: Int get() = frame.height
|
||||
override val shareable: Boolean get() = true
|
||||
override fun draw(canvas: Canvas) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an
|
||||
* immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of
|
||||
@@ -64,32 +42,18 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
override suspend fun decode(): DecodeResult {
|
||||
if (options.webtoonStrips) return decodeStrips()
|
||||
|
||||
val res = resources.source().use {
|
||||
ImageDecoder.open(it.inputStream()).use { dec ->
|
||||
DecodeResultImage(
|
||||
dec.decodeNext(),
|
||||
dec.isHdr,
|
||||
dec.hdrHeadroom,
|
||||
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
|
||||
)
|
||||
}
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
|
||||
}
|
||||
|
||||
val srcWidth = res.width
|
||||
val srcHeight = res.height
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
// newDecoder path: caller wants the raw DecodeResult (e.g. for custom rendering).
|
||||
// Hand it back as-is; sampling is the caller's responsibility.
|
||||
if (options.newDecoder) {
|
||||
return DecodeResult(
|
||||
image = res,
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
val srcWidth = decoder.width
|
||||
val srcHeight = decoder.height
|
||||
|
||||
// Normal path: produce a Bitmap scaled to the requested output size.
|
||||
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
|
||||
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = srcWidth,
|
||||
srcHeight = srcHeight,
|
||||
@@ -98,25 +62,17 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
scale = options.scale,
|
||||
)
|
||||
|
||||
// Copy RGBA pixels from the native buffer into a full-resolution bitmap.
|
||||
// We must do this while `res` (and its native memory) is still alive.
|
||||
// HDR frames are half-float RGBA.
|
||||
val config = if (res.isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
||||
val fullBitmap = createBitmap(srcWidth, srcHeight, config)
|
||||
res.image.rewind()
|
||||
fullBitmap.copyPixelsFromBuffer(res.image)
|
||||
res.frame.close()
|
||||
var bitmap = decoder.decode(sampleSize = sampleSize)
|
||||
decoder.recycle()
|
||||
|
||||
// Downsample if needed. sampleSize is a power-of-two factor; the target
|
||||
// dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour.
|
||||
val bitmap = if (sampleSize > 1) {
|
||||
val scaledWidth = (srcWidth / sampleSize).coerceAtLeast(1)
|
||||
val scaledHeight = (srcHeight / sampleSize).coerceAtLeast(1)
|
||||
val scaled = fullBitmap.scale(scaledWidth, scaledHeight)
|
||||
fullBitmap.recycle()
|
||||
scaled
|
||||
} else {
|
||||
fullBitmap
|
||||
check(bitmap != null) { "Failed to decode image" }
|
||||
|
||||
if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) {
|
||||
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
|
||||
if (hwBitmap != null) {
|
||||
bitmap.recycle()
|
||||
bitmap = hwBitmap
|
||||
}
|
||||
}
|
||||
|
||||
return DecodeResult(
|
||||
@@ -126,66 +82,62 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies bands of rows straight from the native frame into strips, so the page never exists as
|
||||
* one Java bitmap on top of the frame.
|
||||
* Decodes a webtoon page and cuts it into strips, freeing the decoded page once they're copied.
|
||||
*
|
||||
* Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming
|
||||
* in still shows the page's detail, unless that would make the page take more than
|
||||
* [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed.
|
||||
*/
|
||||
private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit {
|
||||
val (frame, isHdr) = resources.source().use {
|
||||
ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr }
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile)
|
||||
}
|
||||
frame.use {
|
||||
val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
||||
val bytesPerPixel = if (isHdr) 8 else 4
|
||||
val rowBytes = frame.width * bytesPerPixel
|
||||
val viewWidth = options.size.widthPx(options.scale) { frame.width }
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = frame.width,
|
||||
srcHeight = frame.height,
|
||||
dstWidth = viewWidth,
|
||||
dstHeight = options.size.heightPx(options.scale) { frame.height },
|
||||
scale = options.scale,
|
||||
)
|
||||
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt()
|
||||
val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth))
|
||||
// Older GPUs can't draw a texture wider than this
|
||||
.coerceAtMost(MAX_STRIP_WIDTH)
|
||||
.coerceIn(1, frame.width)
|
||||
val scale = stripWidth.toFloat() / frame.width
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
// Strips of about the same size in bytes, so each takes about as long to upload
|
||||
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
||||
val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height)
|
||||
val matrix = Matrix().apply { setScale(scale, scale) }
|
||||
val band = createBitmap(frame.width, bandRows, config)
|
||||
val strips = (0..<frame.height step bandRows).map { top ->
|
||||
val rows = min(bandRows, frame.height - top)
|
||||
if (rows != band.height) band.reconfigure(frame.width, rows, config)
|
||||
band.copyPixelsFromBuffer(
|
||||
frame.image.duplicate().apply {
|
||||
position(top * rowBytes)
|
||||
limit((top + rows) * rowBytes)
|
||||
},
|
||||
)
|
||||
val strip = if (stripWidth < frame.width) {
|
||||
Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true)
|
||||
} else {
|
||||
band
|
||||
}
|
||||
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
||||
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
|
||||
val srcWidth = decoder.width
|
||||
val viewWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||
val sampleSize = DecodeUtils.calculateInSampleSize(
|
||||
srcWidth = decoder.width,
|
||||
srcHeight = decoder.height,
|
||||
dstWidth = viewWidth,
|
||||
dstHeight = options.size.heightPx(options.scale) { decoder.height },
|
||||
scale = options.scale,
|
||||
)
|
||||
val page = decoder.decode(sampleSize = sampleSize)
|
||||
decoder.recycle()
|
||||
check(page != null) { "Failed to decode image" }
|
||||
|
||||
val config = page.config ?: Bitmap.Config.ARGB_8888
|
||||
val bytesPerPixel = page.rowBytes / page.width
|
||||
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * page.width / page.height).toInt()
|
||||
val stripWidth = maxOf(viewWidth, min(page.width, budgetWidth))
|
||||
// Older GPUs can't draw a texture wider than this
|
||||
.coerceAtMost(MAX_STRIP_WIDTH)
|
||||
.coerceIn(1, page.width)
|
||||
val scale = stripWidth.toFloat() / page.width
|
||||
|
||||
// Strips of about the same size in bytes, so each takes about as long to upload
|
||||
val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1)
|
||||
val bandRows = (stripRows / scale).toInt().coerceIn(1, page.height)
|
||||
val matrix = Matrix().apply { setScale(scale, scale) }
|
||||
val strips = (0..<page.height step bandRows).map { top ->
|
||||
val rows = min(bandRows, page.height - top)
|
||||
val band = if (stripWidth < page.width) {
|
||||
Bitmap.createBitmap(page, 0, top, page.width, rows, matrix, true)
|
||||
} else {
|
||||
Bitmap.createBitmap(page, 0, top, page.width, rows)
|
||||
}
|
||||
band.recycle()
|
||||
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width)
|
||||
// createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one
|
||||
band.copy(config, false).also { if (band !== page) band.recycle() }
|
||||
}
|
||||
page.recycle()
|
||||
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < srcWidth)
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
|
||||
return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) {
|
||||
return if (options.webtoonStrips || isApplicable(result.source.source())) {
|
||||
ImageDecoder(result.source, options)
|
||||
} else {
|
||||
null
|
||||
@@ -197,12 +149,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
ImageUtil.findImageType(it)
|
||||
}
|
||||
return when (type) {
|
||||
ImageUtil.ImageType.AVIF,
|
||||
ImageUtil.ImageType.JXL,
|
||||
ImageUtil.ImageType.HEIF,
|
||||
ImageUtil.ImageType.JP2,
|
||||
-> true
|
||||
|
||||
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -211,6 +158,10 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
|
||||
|
||||
override fun hashCode() = javaClass.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var displayProfile: ByteArray? = null
|
||||
}
|
||||
}
|
||||
|
||||
private const val STRIP_BYTES = 4 * 1024 * 1024
|
||||
@@ -219,5 +170,5 @@ private const val MAX_PAGE_BYTES = 128 * 1024 * 1024
|
||||
|
||||
private const val MAX_STRIP_WIDTH = 4096
|
||||
|
||||
// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel
|
||||
// Each decode holds the whole page until its strips are copied, and the reader loads pages ahead in parallel
|
||||
private val stripDecodes = Semaphore(1)
|
||||
|
||||
@@ -42,11 +42,3 @@ val Options.webtoonStrips: Boolean
|
||||
get() = getExtra(webtoonStripsKey)
|
||||
|
||||
private val webtoonStripsKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply {
|
||||
extras[newDecoderKey] = enable
|
||||
}
|
||||
val Options.newDecoder: Boolean
|
||||
get() = getExtra(newDecoderKey)
|
||||
|
||||
private val newDecoderKey = Extras.Key(default = false)
|
||||
|
||||
@@ -231,7 +231,7 @@ class DownloadProvider(
|
||||
enableChapterNameHash: Boolean = libraryPreferences.enableChapterNameHash.get(),
|
||||
): String {
|
||||
return ChapterDirNameParts(chapterName, chapterScanlator, chapterUrl)
|
||||
.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
|
||||
.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,18 +254,14 @@ class DownloadProvider(
|
||||
val booleanPairPermutation = listOf(false to false, false to true, true to false, true to true)
|
||||
val othersChapterDirNames = booleanPairPermutation
|
||||
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
||||
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
|
||||
}
|
||||
val splitChapterDirNames = booleanPairPermutation
|
||||
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
||||
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
|
||||
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = true)
|
||||
}
|
||||
|
||||
// Scanlators weren't sanitized for a while. One with a "/" nested its folder, so it was never found anyway.
|
||||
val scanlator = parts.chapterScanlator
|
||||
val unsanitizedScanlatorChapterDirNames = if (scanlator != null && '/' !in scanlator) {
|
||||
booleanPairPermutation.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
|
||||
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
|
||||
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
@@ -276,7 +272,6 @@ class DownloadProvider(
|
||||
// chapter names)
|
||||
add(chapterNameV1)
|
||||
addAll(othersChapterDirNames)
|
||||
addAll(splitChapterDirNames)
|
||||
addAll(unsanitizedScanlatorChapterDirNames)
|
||||
}
|
||||
}
|
||||
@@ -296,6 +291,7 @@ class DownloadProvider(
|
||||
val chapterDirName = parts.dirName(
|
||||
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
|
||||
enableHash = libraryPreferences.enableChapterNameHash.get(),
|
||||
sanitizeScanlator = true,
|
||||
)
|
||||
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
|
||||
|
||||
@@ -334,29 +330,9 @@ private class ChapterDirNameParts(chapterName: String, val chapterScanlator: Str
|
||||
chapterScanlator?.let { DiskUtil.buildValidFilename(it) }
|
||||
}
|
||||
|
||||
private val validDirNames = arrayOfNulls<String>(2)
|
||||
|
||||
private val validChapterNames = arrayOfNulls<String>(2)
|
||||
|
||||
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean): String {
|
||||
val index = if (disallowNonAscii) 1 else 0
|
||||
val dirName = validDirNames[index]
|
||||
// The scanlator and the name are made valid together, as every version before 0.21.0 did
|
||||
?: DiskUtil.buildValidFilename(
|
||||
if (chapterScanlator.isNullOrBlank()) chapterName else "${chapterScanlator}_$chapterName",
|
||||
// Subtract 7 bytes for hash and underscore, 4 bytes for .cbz
|
||||
DiskUtil.MAX_FILE_NAME_BYTES - 11,
|
||||
disallowNonAscii,
|
||||
)
|
||||
.also { validDirNames[index] = it }
|
||||
return if (enableHash) dirName + hashSuffix else dirName
|
||||
}
|
||||
|
||||
/**
|
||||
* The name 0.21.0 and 0.21.1 gave a chapter, which made only the chapter name valid and so differs for names
|
||||
* starting with a space or dot, long names, and non-ASCII scanlators.
|
||||
*/
|
||||
fun splitDirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
|
||||
fun dirName(disallowNonAscii: Boolean, enableHash: Boolean, sanitizeScanlator: Boolean): String {
|
||||
return buildString {
|
||||
if (!chapterScanlator.isNullOrBlank()) {
|
||||
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own
|
||||
|
||||
@@ -192,6 +192,9 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
||||
mangaToUpdate = listToUpdate
|
||||
.filter {
|
||||
when {
|
||||
// Entries added without opening them have no details yet
|
||||
!it.manga.initialized -> true
|
||||
|
||||
it.manga.updateStrategy == UpdateStrategy.ONLY_FETCH_ONCE && it.totalChapters > 0L -> {
|
||||
skippedUpdates.add(
|
||||
it.manga to context.stringResource(MR.strings.skipped_reason_not_always_update),
|
||||
@@ -354,7 +357,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
||||
val update = updateMangaFromRemote(
|
||||
source = source,
|
||||
manga = manga,
|
||||
fetchDetails = libraryPreferences.autoUpdateMetadata.get(),
|
||||
fetchDetails = !manga.initialized || libraryPreferences.autoUpdateMetadata.get(),
|
||||
fetchChapters = true,
|
||||
fetchWindow = fetchWindow,
|
||||
)
|
||||
|
||||
@@ -33,7 +33,6 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.math.RoundingMode
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -78,14 +77,7 @@ class ComickApi(
|
||||
put("status", track.status.toApiListStatus())
|
||||
if (track.last_chapter_read > 0) {
|
||||
putJsonObject("progress") {
|
||||
put(
|
||||
"number",
|
||||
track.last_chapter_read
|
||||
.toBigDecimal()
|
||||
.setScale(2, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros()
|
||||
.toPlainString(),
|
||||
)
|
||||
put("number", track.last_chapter_read)
|
||||
}
|
||||
} else {
|
||||
put("progress", null)
|
||||
@@ -148,14 +140,7 @@ class ComickApi(
|
||||
put("rating", track.score.toInt().takeIf { it > 0 })
|
||||
put("status", track.status.toApiListStatus())
|
||||
putJsonObject("progress") {
|
||||
put(
|
||||
"number",
|
||||
track.last_chapter_read
|
||||
.toBigDecimal()
|
||||
.setScale(2, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros()
|
||||
.toPlainString(),
|
||||
)
|
||||
put("number", track.last_chapter_read.toString())
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
|
||||
@@ -22,8 +22,6 @@ abstract class Installer(private val service: Service) {
|
||||
private val extensionManager: ExtensionManager by lazy { service.appGraph.extensionManager }
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry?>(null)
|
||||
|
||||
// Guarded by itself: cancellation comes from any thread, not only main
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
|
||||
private val cancelListener: (Long) -> Unit = { downloadId -> cancelQueue(downloadId) }
|
||||
@@ -93,15 +91,15 @@ abstract class Installer(private val service: Service) {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
val nextEntry = synchronized(queue) {
|
||||
if (queue.isEmpty()) {
|
||||
service.stopSelf()
|
||||
return
|
||||
}
|
||||
if (!waitingInstall.compareAndSet(null, queue.first())) return
|
||||
if (queue.isEmpty()) {
|
||||
service.stopSelf()
|
||||
return
|
||||
}
|
||||
val nextEntry = queue.first()
|
||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||
queue.removeAt(0)
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,11 +108,11 @@ abstract class Installer(private val service: Service) {
|
||||
@CallSuper
|
||||
open fun onDestroy() {
|
||||
cancelListeners -= cancelListener
|
||||
val remaining = synchronized(queue) { queue.toList().also { queue.clear() } }
|
||||
remaining.forEach {
|
||||
queue.forEach {
|
||||
logcat(LogPriority.INFO) { "The installer stopped before installing ${it.downloadId}" }
|
||||
extensionManager.updateInstallStep(it.downloadId, InstallStep.Error)
|
||||
}
|
||||
queue.clear()
|
||||
waitingInstall.store(null)
|
||||
}
|
||||
|
||||
@@ -127,10 +125,7 @@ abstract class Installer(private val service: Service) {
|
||||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.load()
|
||||
// Every new install cancels its own previous attempt first, which mustn't hit another extension's install
|
||||
val toCancel = synchronized(queue) { queue.find { it.downloadId == downloadId } }
|
||||
?: waitingInstall?.takeIf { it.downloadId == downloadId }
|
||||
?: return
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
|
||||
+1
-11
@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.extension.installer
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -58,16 +57,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
||||
return
|
||||
}
|
||||
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
service.startActivity(userAction)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Some devices have the system installer's confirmation screen disabled or removed
|
||||
logcat(LogPriority.WARN, e) { "Nothing can confirm the install" }
|
||||
activeSession?.let { (_, sessionId) ->
|
||||
runCatching { packageInstaller.abandonSession(sessionId) }
|
||||
}
|
||||
continueQueue(InstallStep.Error)
|
||||
}
|
||||
service.startActivity(userAction)
|
||||
}
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
continueQueue(InstallStep.Idle)
|
||||
|
||||
@@ -169,7 +169,7 @@ private fun Int.toTriStateFilter(): TriState {
|
||||
Filter.TriState.STATE_IGNORE -> TriState.DISABLED
|
||||
Filter.TriState.STATE_INCLUDE -> TriState.ENABLED_IS
|
||||
Filter.TriState.STATE_EXCLUDE -> TriState.ENABLED_NOT
|
||||
else -> TriState.DISABLED
|
||||
else -> throw IllegalStateException("Unknown TriState state: $this")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,6 @@ class MainActivity : BaseActivity() {
|
||||
Migrator.awaitAndRelease()
|
||||
isAppMigrating.value = false
|
||||
}
|
||||
// Uncaught on purpose: a failed database migration otherwise leaves the migrating screen up for good,
|
||||
// while the crash screen tells what went wrong and lets the logs be shared
|
||||
lifecycleScope.launch { databaseRepository.awaitOpen() }
|
||||
|
||||
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
|
||||
if (!isTaskRoot) {
|
||||
@@ -340,16 +337,6 @@ class MainActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFullyDrawn() {
|
||||
try {
|
||||
super.reportFullyDrawn()
|
||||
} catch (e: SecurityException) {
|
||||
// Some Samsung Android 9 to 11 builds deny the platform's fully drawn broadcast
|
||||
// and the report only feeds startup metrics, so it shouldn't crash the app
|
||||
logcat(LogPriority.WARN, e)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleOnNewIntent(context: Context, navigator: Navigator) {
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
@@ -43,7 +43,9 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.transition.platform.MaterialContainerTransform
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.presentation.reader.DisplayRefreshHost
|
||||
@@ -56,6 +58,7 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars
|
||||
import eu.kanade.presentation.reader.components.ChapterNavigatorType
|
||||
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
|
||||
@@ -74,7 +77,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
||||
@@ -99,6 +101,7 @@ import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class ReaderActivity : BaseActivity() {
|
||||
@@ -473,7 +476,7 @@ class ReaderActivity : BaseActivity() {
|
||||
onShare = ::shareChapter.takeIf { isHttpSource },
|
||||
|
||||
chapterNavigatorType = if (!verticalNavigator) {
|
||||
if (state.viewer is R2LPagerViewer || (state.viewer as? WebGpuViewer)?.isReversed ?: false) {
|
||||
if (state.viewer is R2LPagerViewer) {
|
||||
ChapterNavigatorType.HORIZONTAL_RTL
|
||||
} else {
|
||||
ChapterNavigatorType.HORIZONTAL_LTR
|
||||
@@ -860,6 +863,10 @@ class ReaderActivity : BaseActivity() {
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
preferences.displayProfile.changes()
|
||||
.onEach { setDisplayProfile(it) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
readerPreferences.keepScreenOn.changes()
|
||||
.onEach(::setKeepScreenOn)
|
||||
.launchIn(lifecycleScope)
|
||||
@@ -887,6 +894,25 @@ class ReaderActivity : BaseActivity() {
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the display profile to [path].
|
||||
*/
|
||||
private fun setDisplayProfile(path: String) {
|
||||
val file = UniFile.fromUri(baseContext, path.toUri())
|
||||
if (file != null && file.exists()) {
|
||||
val inputStream = file.openInputStream()
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
inputStream.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
val data = outputStream.toByteArray()
|
||||
SubsamplingScaleImageView.setDisplayProfile(data)
|
||||
ImageDecoder.displayProfile = data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the keep screen on mode according to [enabled].
|
||||
*/
|
||||
|
||||
@@ -231,23 +231,14 @@ internal class HttpPageLoader(
|
||||
val imageUrl = page.imageUrl!!
|
||||
|
||||
if (force || !chapterCache.isImageInCache(imageUrl)) {
|
||||
val live = page.downloadStream
|
||||
page.status = Page.State.DownloadImage
|
||||
try {
|
||||
val imageResponse = source.getImage(page)
|
||||
chapterCache.putImageToCache(imageUrl, imageResponse, live)
|
||||
live?.finish()
|
||||
} catch (e: Throwable) {
|
||||
live?.finish(e)
|
||||
throw e
|
||||
}
|
||||
val imageResponse = source.getImage(page)
|
||||
chapterCache.putImageToCache(imageUrl, imageResponse)
|
||||
}
|
||||
|
||||
page.downloadStream = null
|
||||
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
|
||||
page.status = Page.State.Ready
|
||||
} catch (e: Throwable) {
|
||||
page.downloadStream = null
|
||||
if (e is CancellationException) {
|
||||
// Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in
|
||||
// the queue, otherwise it's stuck in a state no later pass will pick up.
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is
|
||||
* closed, it stops keeping them; [append] never throws, so the download itself carries on.
|
||||
*/
|
||||
class DownloadStream {
|
||||
private val lock = Object()
|
||||
private var data = ByteArray(1 shl 16)
|
||||
private var size = 0
|
||||
private var done = false
|
||||
private var error: Throwable? = null
|
||||
|
||||
/** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */
|
||||
fun reserve(bytes: Long) {
|
||||
synchronized(lock) {
|
||||
if (error == null && bytes > data.size && bytes <= MAX_RESERVE) {
|
||||
try {
|
||||
data = data.copyOf(bytes.toInt())
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// Only a hint.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun append(src: ByteArray, offset: Int, length: Int) {
|
||||
synchronized(lock) {
|
||||
if (error != null || done) return
|
||||
val needed = size.toLong() + length
|
||||
if (needed > MAX_BYTES) {
|
||||
// Too big to decode while downloading; the whole-file decode takes over.
|
||||
drop("Download too large to stream")
|
||||
return
|
||||
}
|
||||
if (needed > data.size) {
|
||||
data = try {
|
||||
data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt())
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// As too large: the download mustn't fail over this copy.
|
||||
drop("Out of memory buffering the download")
|
||||
return
|
||||
}
|
||||
}
|
||||
System.arraycopy(src, offset, data, size, length)
|
||||
size += length
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the bytes; readers throw [error] if given. */
|
||||
fun finish(error: Throwable? = null) {
|
||||
synchronized(lock) {
|
||||
done = true
|
||||
if (this.error == null) this.error = error
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
/** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */
|
||||
fun reader(): Reader = Reader()
|
||||
|
||||
// Rather than hold a second copy of the page.
|
||||
private fun drop(why: String) {
|
||||
if (error == null) error = IOException(why)
|
||||
data = ByteArray(0)
|
||||
size = 0
|
||||
lock.notifyAll()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// As the decoder's own input limit.
|
||||
const val MAX_BYTES = 1L shl 30
|
||||
const val MAX_RESERVE = 64L shl 20
|
||||
}
|
||||
|
||||
inner class Reader : InputStream() {
|
||||
private var position = 0
|
||||
|
||||
/** All read; throws if the download failed. */
|
||||
val ended: Boolean
|
||||
get() = synchronized(lock) {
|
||||
checkError()
|
||||
done && position >= size
|
||||
}
|
||||
|
||||
override fun read(): Int {
|
||||
val b = ByteArray(1)
|
||||
return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF
|
||||
}
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||
synchronized(lock) {
|
||||
if (len == 0) return 0
|
||||
while (position >= size && !done && error == null) lock.wait()
|
||||
checkError()
|
||||
if (position >= size) return -1
|
||||
val n = minOf(len, size - position)
|
||||
System.arraycopy(data, position, b, off, n)
|
||||
position += n
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position }
|
||||
|
||||
/** Waits up to [timeoutMs] for [min] unread bytes or the end. */
|
||||
fun await(min: Int, timeoutMs: Long) {
|
||||
synchronized(lock) {
|
||||
val until = System.nanoTime() + timeoutMs * 1_000_000
|
||||
while (size - position < min && !done && error == null) {
|
||||
val left = (until - System.nanoTime()) / 1_000_000
|
||||
if (left <= 0) return
|
||||
lock.wait(left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(lock) { drop("No longer read") }
|
||||
}
|
||||
|
||||
private fun checkError() {
|
||||
error?.let { throw IOException("Download failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,4 @@ open class ReaderPage(
|
||||
) : Page(index, url, imageUrl, null) {
|
||||
|
||||
open lateinit var chapter: ReaderChapter
|
||||
|
||||
/** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */
|
||||
@Volatile
|
||||
var downloadStream: DownloadStream? = null
|
||||
}
|
||||
|
||||
@@ -142,11 +142,6 @@ class ReaderPreferences(
|
||||
false,
|
||||
)
|
||||
|
||||
val dualPageView: Preference<DualPageView> = preferenceStore.getEnum(
|
||||
"pref_dual_page_view",
|
||||
DualPageView.NEVER,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region Color filter
|
||||
@@ -204,24 +199,6 @@ class ReaderPreferences(
|
||||
|
||||
// endregion
|
||||
|
||||
// region WebGpu
|
||||
|
||||
val transitionAnimation: Preference<TransitionAnimation> =
|
||||
preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC)
|
||||
|
||||
val transitionAnimationDual: Preference<TransitionAnimation> =
|
||||
preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC)
|
||||
|
||||
val cutoutMode: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID)
|
||||
|
||||
val cutoutModeDual: Preference<CutoutMode> = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE)
|
||||
|
||||
val continuousMinWidth: Preference<Int> = preferenceStore.getInt("webgpu_continuous_minwidth", 100)
|
||||
|
||||
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
|
||||
|
||||
// endregion
|
||||
|
||||
enum class FlashColor {
|
||||
BLACK,
|
||||
WHITE,
|
||||
@@ -234,14 +211,8 @@ class ReaderPreferences(
|
||||
val shouldInvertVertical: Boolean = false,
|
||||
) {
|
||||
NONE(MR.strings.tapping_inverted_none),
|
||||
HORIZONTAL(
|
||||
MR.strings.tapping_inverted_horizontal,
|
||||
shouldInvertHorizontal = true,
|
||||
),
|
||||
VERTICAL(
|
||||
MR.strings.tapping_inverted_vertical,
|
||||
shouldInvertVertical = true,
|
||||
),
|
||||
HORIZONTAL(MR.strings.tapping_inverted_horizontal, shouldInvertHorizontal = true),
|
||||
VERTICAL(MR.strings.tapping_inverted_vertical, shouldInvertVertical = true),
|
||||
BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true),
|
||||
}
|
||||
|
||||
@@ -252,43 +223,6 @@ class ReaderPreferences(
|
||||
LOWEST(47),
|
||||
}
|
||||
|
||||
enum class TransitionAnimation(val titleRes: StringResource) {
|
||||
BASIC(MR.strings.transition_animation_basic),
|
||||
FLIP(MR.strings.transition_animation_flip),
|
||||
FLIP_LEFT(MR.strings.transition_animation_flip_left),
|
||||
FLIP_RIGHT(
|
||||
MR.strings.transition_animation_flip_right,
|
||||
),
|
||||
STACK_LEFT(MR.strings.transition_animation_stack_left),
|
||||
STACK_RIGHT(MR.strings.transition_animation_stack_right),
|
||||
STACK_UP(
|
||||
MR.strings.transition_animation_stack_up,
|
||||
),
|
||||
STACK_DOWN(MR.strings.transition_animation_stack_down),
|
||||
SPHERE(MR.strings.transition_animation_sphere),
|
||||
CUBE_INSIDE(
|
||||
MR.strings.transition_animation_cube_inside,
|
||||
),
|
||||
CUBE_OUTSIDE(MR.strings.transition_animation_cube_outside),
|
||||
FADE(MR.strings.transition_animation_fade),
|
||||
FADE_WHITE(
|
||||
MR.strings.transition_animation_fade_white,
|
||||
),
|
||||
NONE(MR.strings.transition_animation_none),
|
||||
}
|
||||
|
||||
enum class CutoutMode(val titleRes: StringResource) {
|
||||
IGNORE(MR.strings.cutout_mode_ignore),
|
||||
AVOID(MR.strings.cutout_mode_avoid),
|
||||
SHIFT(MR.strings.cutout_mode_shift),
|
||||
}
|
||||
|
||||
enum class DualPageView(val titleRes: StringResource) {
|
||||
NEVER(MR.strings.dual_page_view_never),
|
||||
ALWAYS(MR.strings.dual_page_view_always),
|
||||
WIDE(MR.strings.dual_page_view_wide),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WEBTOON_PADDING_MIN = 0
|
||||
const val WEBTOON_PADDING_MAX = 25
|
||||
@@ -313,13 +247,6 @@ class ReaderPreferences(
|
||||
MR.strings.scale_type_smart_fit,
|
||||
)
|
||||
|
||||
val ImageScaleTypeWebGpuViewer = listOf(
|
||||
MR.strings.scale_type_fit_screen,
|
||||
MR.strings.scale_type_fit_width,
|
||||
MR.strings.scale_type_fit_height,
|
||||
MR.strings.scale_type_original_size,
|
||||
)
|
||||
|
||||
val ZoomStart = listOf(
|
||||
MR.strings.zoom_start_automatic,
|
||||
MR.strings.zoom_start_left,
|
||||
|
||||
@@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerContinuous
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
import mihon.app.di.appGraph
|
||||
import mihon.icons.custommaterialsymbols.CustomMaterialSymbols
|
||||
import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight
|
||||
import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip
|
||||
@@ -77,16 +74,6 @@ enum class ReadingMode(
|
||||
}
|
||||
|
||||
fun toViewer(preference: Int?, activity: ReaderActivity): Viewer {
|
||||
if (activity.appGraph.basePreferences.highQualityRenderer.get()) {
|
||||
return when (fromPreference(preference)) {
|
||||
LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false)
|
||||
RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false)
|
||||
VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true)
|
||||
WEBTOON -> WebGpuViewerContinuous(activity, useGap = false)
|
||||
CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true)
|
||||
DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference")
|
||||
}
|
||||
}
|
||||
return when (fromPreference(preference)) {
|
||||
LEFT_TO_RIGHT -> L2RPagerViewer(activity)
|
||||
RIGHT_TO_LEFT -> R2LPagerViewer(activity)
|
||||
|
||||
@@ -41,6 +41,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
|
||||
/**
|
||||
* A wrapper view for showing page image.
|
||||
@@ -240,6 +241,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
removeView(pageView)
|
||||
|
||||
pageView = SubsamplingScaleImageView(context).apply {
|
||||
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
|
||||
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
|
||||
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
||||
setMinimumTileDpi(180)
|
||||
@@ -300,6 +302,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
isVisible = true
|
||||
}
|
||||
is BufferedSource -> {
|
||||
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
|
||||
setImage(ImageSource.inputStream(data.inputStream()))
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
+10
-26
@@ -26,7 +26,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
/**
|
||||
* Holds preprocessed items so they don't get removed when changing chapter
|
||||
*/
|
||||
private var preprocessed: List<InsertPage> = emptyList()
|
||||
private var preprocessed: MutableMap<Int, InsertPage> = mutableMapOf()
|
||||
|
||||
var nextTransition: ChapterTransition.Next? = null
|
||||
private set
|
||||
@@ -68,15 +68,13 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
val lastPage = pages.last()
|
||||
|
||||
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
|
||||
// placed by their index, so only this chapter's belong here.
|
||||
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
|
||||
.sortedByDescending { it.index }
|
||||
.forEach { page ->
|
||||
if (lastPage.index == page.index) {
|
||||
insertPageLastPage = page
|
||||
// Insert preprocessed pages into current page list
|
||||
preprocessed.keys.sortedDescending()
|
||||
.forEach { key ->
|
||||
if (lastPage.index == key) {
|
||||
insertPageLastPage = preprocessed[key]
|
||||
}
|
||||
pages.add(page.index + 1, page)
|
||||
preprocessed[key]?.let { pages.add(key + 1, it) }
|
||||
}
|
||||
|
||||
newItems.addAll(pages)
|
||||
@@ -98,8 +96,6 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
chapters.nextChapter?.pages?.let(newItems::addAll)
|
||||
|
||||
val currentItem = items.getOrNull(viewer.pager.currentItem)
|
||||
|
||||
// Resets double-page splits, else insert pages get misplaced
|
||||
items.filterIsInstance<InsertPage>().also { items.removeAll(it) }
|
||||
|
||||
@@ -107,19 +103,10 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
newItems.reverse()
|
||||
}
|
||||
|
||||
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
|
||||
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
|
||||
preprocessed = mutableMapOf()
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
|
||||
// The pager only follows the pages it has created, so before its first layout it would stay at the same
|
||||
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
|
||||
// chapter
|
||||
val currentPosition = items.indexOf(currentItem)
|
||||
if (currentPosition != -1 && currentPosition != viewer.pager.currentItem) {
|
||||
viewer.pager.setCurrentItem(currentPosition, false)
|
||||
}
|
||||
|
||||
// Will skip insert page otherwise
|
||||
if (insertPageLastPage != null) {
|
||||
viewer.moveToPage(insertPageLastPage)
|
||||
@@ -164,12 +151,9 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
val currentIndex = items.indexOf(currentPage)
|
||||
|
||||
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
|
||||
// Put aside preprocessed pages for next chapter so they don't get removed when changing chapter
|
||||
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
|
||||
val chapterId = newPage.chapter.chapter.id
|
||||
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
|
||||
preprocessed = preprocessed + newPage
|
||||
}
|
||||
preprocessed[newPage.index] = newPage
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
/**
|
||||
* Configuration used by pager viewers.
|
||||
*/
|
||||
class WebGpuConfig(
|
||||
private val viewer: WebGpuViewer,
|
||||
scope: CoroutineScope,
|
||||
readerPreferences: ReaderPreferences,
|
||||
) : ViewerConfig(readerPreferences, scope) {
|
||||
|
||||
var theme = readerPreferences.readerTheme.get()
|
||||
private set
|
||||
|
||||
var automaticBackground = false
|
||||
private set
|
||||
|
||||
var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
var imageScaleType = 1
|
||||
private set
|
||||
|
||||
var imageZoomType = ReaderPageImageView.ZoomStartPosition.LEFT
|
||||
private set
|
||||
|
||||
var imageCropBorders = false
|
||||
private set
|
||||
|
||||
var imageCropBordersWebtoon = false
|
||||
private set
|
||||
|
||||
var navigateToPan = false
|
||||
private set
|
||||
|
||||
var landscapeZoom = false
|
||||
private set
|
||||
|
||||
var transitionAnimation = ReaderPreferences.TransitionAnimation.BASIC
|
||||
private set
|
||||
|
||||
var transitionAnimationDual = ReaderPreferences.TransitionAnimation.BASIC
|
||||
private set
|
||||
|
||||
var cutoutMode = ReaderPreferences.CutoutMode.AVOID
|
||||
private set
|
||||
|
||||
var cutoutModeDual = ReaderPreferences.CutoutMode.AVOID
|
||||
private set
|
||||
|
||||
var dualPageView = ReaderPreferences.DualPageView.NEVER
|
||||
private set
|
||||
|
||||
var continuousMinWidth = 100
|
||||
private set
|
||||
|
||||
var zoomOutDisabled = false
|
||||
private set
|
||||
|
||||
var continuousGap = 10
|
||||
private set
|
||||
|
||||
init {
|
||||
readerPreferences.readerTheme.register(
|
||||
{
|
||||
theme = it
|
||||
automaticBackground = it == 3
|
||||
},
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.imageScaleType.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.zoomStart.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.cropBorders.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.cropBordersWebtoon.register(
|
||||
{ imageCropBordersWebtoon = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.navigateToPan.register({ navigateToPan = it })
|
||||
|
||||
readerPreferences.landscapeZoom.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.navigationModePager.register({ navigationMode = it }, { updateNavigation(navigationMode) })
|
||||
|
||||
readerPreferences.pagerNavInverted.register({ tappingInverted = it }, { navigator.invertMode = it })
|
||||
readerPreferences.pagerNavInverted.changes().drop(1).onEach { navigationModeChangedListener?.invoke() }
|
||||
.launchIn(scope)
|
||||
|
||||
readerPreferences.dualPageSplitPaged.register(
|
||||
{ dualPageSplit = it },
|
||||
{
|
||||
imagePropertyChangedListener?.invoke()
|
||||
dualPageSplitChangedListener?.invoke(it)
|
||||
},
|
||||
)
|
||||
|
||||
readerPreferences.dualPageInvertPaged.register(
|
||||
{ dualPageInvert = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.dualPageRotateToFit.register(
|
||||
{ dualPageRotateToFit = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.dualPageRotateToFitInvert.register(
|
||||
{ dualPageRotateToFitInvert = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.transitionAnimation.register(
|
||||
{ transitionAnimation = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.transitionAnimationDual.register(
|
||||
{ transitionAnimationDual = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.cutoutMode.register(
|
||||
{ cutoutMode = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.cutoutModeDual.register(
|
||||
{ cutoutModeDual = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.dualPageView.register(
|
||||
{ dualPageView = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.continuousMinWidth.register(
|
||||
{ continuousMinWidth = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.webtoonDisableZoomOut.register(
|
||||
{ zoomOutDisabled = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
|
||||
readerPreferences.continuousGap.register(
|
||||
{ continuousGap = it },
|
||||
{ imagePropertyChangedListener?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun zoomTypeFromPreference(value: Int) {
|
||||
imageZoomType = when (value) {
|
||||
// Auto
|
||||
1 -> if (viewer.isReversed) {
|
||||
ReaderPageImageView.ZoomStartPosition.RIGHT
|
||||
} else {
|
||||
ReaderPageImageView.ZoomStartPosition.LEFT
|
||||
}
|
||||
// Left
|
||||
2 -> ReaderPageImageView.ZoomStartPosition.LEFT
|
||||
// Right
|
||||
3 -> ReaderPageImageView.ZoomStartPosition.RIGHT
|
||||
// Center
|
||||
else -> ReaderPageImageView.ZoomStartPosition.CENTER
|
||||
}
|
||||
}
|
||||
|
||||
override var navigator: ViewerNavigation = defaultNavigation()
|
||||
set(value) {
|
||||
field = value.also { it.invertMode = this.tappingInverted }
|
||||
}
|
||||
|
||||
override fun defaultNavigation(): ViewerNavigation {
|
||||
return if (viewer.isVertical) {
|
||||
LNavigation()
|
||||
} else {
|
||||
RightAndLeftNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateNavigation(navigationMode: Int) {
|
||||
navigator = when (navigationMode) {
|
||||
0 -> defaultNavigation()
|
||||
1 -> LNavigation()
|
||||
2 -> KindlishNavigation()
|
||||
3 -> EdgeNavigation()
|
||||
4 -> RightAndLeftNavigation()
|
||||
5 -> DisabledNavigation()
|
||||
else -> defaultNavigation()
|
||||
}
|
||||
navigationModeChangedListener?.invoke()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-102
@@ -1,102 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
|
||||
|
||||
import ca.mpreg.webgpuviewer.ImageViewContinuous
|
||||
import ca.mpreg.webgpuviewer.viewer.ImagePage
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.max
|
||||
|
||||
class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) :
|
||||
WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) {
|
||||
|
||||
override val isContinuous: Boolean get() = true
|
||||
|
||||
// How many pages the viewport shows depends on the zoom, and a page on screen has to be
|
||||
// decoded rather than merely reserved - so the window follows what the last frame reached.
|
||||
override val preloadAhead get() = max(3, state.pagesBelow)
|
||||
override val preloadBehind get() = max(2, state.pagesAbove)
|
||||
|
||||
override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow)
|
||||
|
||||
private val state get() = (pager as ImageViewContinuous).state
|
||||
|
||||
init {
|
||||
state.backgroundColor = readerBackgroundColor()
|
||||
|
||||
state.onViewport = { readThrough ->
|
||||
val pageChanged = readThrough !== lastReadThrough
|
||||
if (pageChanged) {
|
||||
lastReadThrough = readThrough
|
||||
readThrough?.let { select(it) }
|
||||
}
|
||||
|
||||
val page0 = state.getPage(0)
|
||||
val edge = (page0 is TransitionPage && page0.prevChapter == null) ||
|
||||
(readThrough is TransitionPage && readThrough.nextChapter == null)
|
||||
|
||||
val first = wasAtEdge == null
|
||||
val edgeChanged = edge != wasAtEdge
|
||||
wasAtEdge = edge
|
||||
if (edge) {
|
||||
if (edgeChanged) scope.launch { activity.showMenu() }
|
||||
} else if (!first && (edgeChanged || pageChanged) && !activity.isScrollingThroughPages) {
|
||||
scope.launch { activity.hideMenu() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Null until the first frame, so opening mid-document neither shows nor hides. */
|
||||
@Volatile
|
||||
private var wasAtEdge: Boolean? = null
|
||||
|
||||
@Volatile
|
||||
private var lastReadThrough: ImagePage? = null
|
||||
|
||||
/**
|
||||
* Mark [imagePage] read - its bottom has cleared the viewport, which is later than
|
||||
* onPageChange's "top arrived" and the reason this mode selects from here: scrolling up to
|
||||
* reveal the page above would otherwise walk progress back past pages still on screen.
|
||||
*/
|
||||
private fun select(imagePage: ImagePage) {
|
||||
val page = readerPageFor(imagePage) ?: return
|
||||
scope.launch { activity.onPageSelected(page) }
|
||||
}
|
||||
|
||||
private fun readerPageFor(imagePage: ImagePage): ReaderPage? {
|
||||
// Off the page itself: a transition page is never currentPage (the scroll walk stops
|
||||
// before it), so it is the first thing a preload evicts.
|
||||
if (imagePage is TransitionPage) return imagePage.prevChapter?.pages?.lastOrNull()
|
||||
|
||||
// Only an image page needs the lookup - nothing on it names its ReaderPage. Evicted
|
||||
// means scrolled well clear of, so there is no progress left to report.
|
||||
return (viewerPageFor(imagePage) as? ViewerReaderPage)?.page
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
state.onViewport = null
|
||||
lastReadThrough = null
|
||||
wasAtEdge = null
|
||||
super.destroy()
|
||||
}
|
||||
|
||||
private fun scrollByHalfPage(direction: Int) {
|
||||
state.animateScroll(direction * state.height / 2f)
|
||||
}
|
||||
|
||||
override fun moveRight() = scrollByHalfPage(1)
|
||||
|
||||
override fun moveLeft() = scrollByHalfPage(-1)
|
||||
|
||||
override fun moveToPage(page: ReaderPage) {
|
||||
super.moveToPage(page)
|
||||
wasAtEdge = null
|
||||
lastReadThrough = null
|
||||
// Also for a jump to the page already showing, which turns nothing to slide in.
|
||||
state.resetScroll()
|
||||
}
|
||||
|
||||
override fun animateTurn(direction: Int, fromSpread: ImagePage) {
|
||||
state.animateSlideIn(direction)
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -243,9 +243,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
|
||||
|
||||
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
|
||||
// A tap is confirmed after a delay, by when the reader can be gone, and scrolling a detached list
|
||||
// lays out pages that can't compose
|
||||
if (!tapDuringManualScroll && isAttachedToWindow) {
|
||||
if (!tapDuringManualScroll) {
|
||||
tapListener?.invoke(ev)
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package mihon.core.migration
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
class MigrationJobFactory(
|
||||
@@ -22,18 +20,7 @@ class MigrationJobFactory(
|
||||
}
|
||||
async {
|
||||
val prev = acc.await()
|
||||
// A failure would cancel the rest of the chain and leave the app waiting on it for good
|
||||
val ran = try {
|
||||
migration(migrationContext)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) {
|
||||
"Migration failed: { name = ${migration::class.simpleName}, version = ${migration.version} }"
|
||||
}
|
||||
false
|
||||
}
|
||||
ran || prev
|
||||
migration(migrationContext) || prev
|
||||
}
|
||||
} else {
|
||||
logcat {
|
||||
|
||||
@@ -69,7 +69,7 @@ class UpdateMangaFromRemote(
|
||||
fetchDetails = fetchDetails,
|
||||
fetchChapters = fetchChapters,
|
||||
)
|
||||
awaitUpdateFromSource(manga, update.manga, manualFetch)
|
||||
awaitUpdateFromSource(manga, update.manga, fetchDetails, manualFetch)
|
||||
val newChapters = syncChaptersWithSource.await(
|
||||
rawSourceChapters = update.chapters,
|
||||
manga = manga,
|
||||
@@ -89,6 +89,7 @@ class UpdateMangaFromRemote(
|
||||
private suspend fun awaitUpdateFromSource(
|
||||
localManga: Manga,
|
||||
remoteManga: SManga,
|
||||
fetchDetails: Boolean,
|
||||
manualFetch: Boolean,
|
||||
): Boolean {
|
||||
val remoteTitle = try {
|
||||
@@ -134,7 +135,7 @@ class UpdateMangaFromRemote(
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
status = remoteManga.status.toLong(),
|
||||
updateStrategy = remoteManga.update_strategy,
|
||||
initialized = true,
|
||||
initialized = localManga.initialized || fetchDetails,
|
||||
memo = remoteManga.memo,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSource
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
|
||||
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
|
||||
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
|
||||
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import okio.Buffer
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.junit.jupiter.api.parallel.Execution
|
||||
import org.junit.jupiter.api.parallel.ExecutionMode
|
||||
import java.io.IOException
|
||||
|
||||
@Execution(ExecutionMode.CONCURRENT)
|
||||
class BackupStreamTest {
|
||||
|
||||
private val parser = ProtoBuf
|
||||
|
||||
@Test
|
||||
fun `writer output is the plain encoding`() {
|
||||
val backup = backup()
|
||||
|
||||
val bytes = write(backup)
|
||||
|
||||
bytes shouldBe encode(backup)
|
||||
encode(parser.decodeFromByteArray(Backup.serializer(), bytes)) shouldBe encode(backup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader reads the plain encoding`() {
|
||||
val backup = backup()
|
||||
|
||||
val (entries, metadata) = read(encode(backup))
|
||||
|
||||
entries.map(::encode) shouldBe backup.backupManga.map(::encode)
|
||||
encode(metadata) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader counts entries without decoding them`() {
|
||||
val backup = backup()
|
||||
val reader = BackupReader(Buffer().write(encode(backup)), parser)
|
||||
|
||||
var count = 0
|
||||
while (reader.nextEntry()) {
|
||||
reader.skipEntry()
|
||||
count++
|
||||
}
|
||||
|
||||
count shouldBe backup.backupManga.size
|
||||
encode(reader.readMetadata()) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader takes fields in any order`() {
|
||||
val backup = backup()
|
||||
val (first, second, third) = backup.backupManga
|
||||
val input = Buffer()
|
||||
.write(encode(Backup(emptyList(), backupSources = backup.backupSources)))
|
||||
.write(encode(Backup(listOf(first))))
|
||||
// Fields Backup doesn't know, one of each remaining wire type
|
||||
.writeByte(3 shl 3 or 0).writeByte(0x96).writeByte(0x01)
|
||||
.writeByte(4 shl 3 or 1).writeLongLe(42)
|
||||
.write(encode(Backup(listOf(second), backupCategories = backup.backupCategories)))
|
||||
.writeByte(5 shl 3 or 5).writeIntLe(42)
|
||||
.write(encode(Backup(emptyList(), backupPreferences = backup.backupPreferences)))
|
||||
.write(encode(Backup(listOf(third), backupExtensionStores = backup.backupExtensionStores)))
|
||||
.write(encode(Backup(emptyList(), backupSourcePreferences = backup.backupSourcePreferences)))
|
||||
.readByteArray()
|
||||
|
||||
val (entries, metadata) = read(input)
|
||||
|
||||
entries.map(::encode) shouldBe backup.backupManga.map(::encode)
|
||||
encode(metadata) shouldBe encode(backup.copy(backupManga = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty backup`() {
|
||||
val sink = Buffer()
|
||||
val writer = BackupWriter(sink, parser)
|
||||
writer.writeMetadata(Backup(emptyList()))
|
||||
|
||||
writer.isEmpty shouldBe true
|
||||
sink.size shouldBe 0L
|
||||
|
||||
val (entries, metadata) = read(ByteArray(0))
|
||||
entries shouldBe emptyList()
|
||||
encode(metadata) shouldBe ByteArray(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated input throws`() {
|
||||
val bytes = encode(backup())
|
||||
|
||||
// Cut inside the last entry, inside the metadata, and right after a tag
|
||||
val entryEnd = encode(Backup(backup().backupManga)).size
|
||||
listOf(entryEnd - 1, bytes.size - 1, entryEnd + 1).forEach { size ->
|
||||
assertThrows<IOException> { read(bytes.copyOf(size)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage input throws`() {
|
||||
listOf(
|
||||
// Wire type 7 doesn't exist
|
||||
byteArrayOf(0x0F),
|
||||
// Field number 0
|
||||
byteArrayOf(0x02, 0x00),
|
||||
// A varint that never ends
|
||||
ByteArray(11) { 0xFF.toByte() },
|
||||
// An entry longer than the input
|
||||
byteArrayOf(0x0A, 0x10, 0x01),
|
||||
// An entry that isn't a BackupManga
|
||||
byteArrayOf(0x0A, 0x02, 0x0F, 0x00),
|
||||
).forEach { input ->
|
||||
assertThrows<IOException> { read(input) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(backup: Backup): ByteArray {
|
||||
val sink = Buffer()
|
||||
val writer = BackupWriter(sink, parser)
|
||||
backup.backupManga.forEach(writer::writeEntry)
|
||||
writer.writeMetadata(backup.copy(backupManga = emptyList()))
|
||||
writer.isEmpty shouldBe false
|
||||
return sink.readByteArray()
|
||||
}
|
||||
|
||||
private fun read(bytes: ByteArray): Pair<List<BackupManga>, Backup> {
|
||||
val reader = BackupReader(Buffer().write(bytes), parser)
|
||||
val entries = buildList {
|
||||
while (reader.nextEntry()) add(reader.readEntry())
|
||||
}
|
||||
return entries to reader.readMetadata()
|
||||
}
|
||||
|
||||
private fun encode(backup: Backup) = parser.encodeToByteArray(Backup.serializer(), backup)
|
||||
|
||||
private fun encode(manga: BackupManga) = parser.encodeToByteArray(BackupManga.serializer(), manga)
|
||||
|
||||
private fun backup() = Backup(
|
||||
backupManga = listOf(
|
||||
BackupManga(
|
||||
source = 1L,
|
||||
url = "/manga/1",
|
||||
title = "First",
|
||||
genre = listOf("Action", "Drama"),
|
||||
chapters = listOf(
|
||||
BackupChapter(url = "/chapter/1", name = "Chapter 1", read = true, chapterNumber = 1f),
|
||||
BackupChapter(url = "/chapter/2", name = "Chapter 2", bookmark = true, lastPageRead = 4),
|
||||
),
|
||||
categories = listOf(0L, 1L),
|
||||
tracking = listOf(BackupTracking(syncId = 2, libraryId = 3L, title = "First", mediaId = 7L)),
|
||||
history = listOf(BackupHistory(url = "/chapter/1", lastRead = 1_700_000_000_000L, readDuration = 60)),
|
||||
excludedScanlators = listOf("Group"),
|
||||
),
|
||||
BackupManga(source = 2L, url = "/manga/2", title = "Second", favorite = false),
|
||||
BackupManga(
|
||||
source = 1L,
|
||||
url = "/manga/3",
|
||||
title = "Third",
|
||||
description = "x".repeat(300),
|
||||
chapters = List(200) { BackupChapter(url = "/chapter/3/$it", name = "Chapter $it") },
|
||||
),
|
||||
),
|
||||
backupCategories = listOf(
|
||||
BackupCategory(name = "Reading", order = 0),
|
||||
BackupCategory(name = "Done", order = 1),
|
||||
),
|
||||
backupSources = listOf(BackupSource(name = "One", sourceId = 1L), BackupSource(name = "Two", sourceId = 2L)),
|
||||
backupPreferences = listOf(
|
||||
BackupPreference("flag", BooleanPreferenceValue(true)),
|
||||
BackupPreference("tags", StringSetPreferenceValue(setOf("a", "b"))),
|
||||
),
|
||||
backupSourcePreferences = listOf(
|
||||
BackupSourcePreferences("source_1", listOf(BackupPreference("lang", StringPreferenceValue("en")))),
|
||||
),
|
||||
backupExtensionStores = listOf(
|
||||
BackupExtensionStore(
|
||||
indexUrl = "https://example.org/index.min.json",
|
||||
name = "Store",
|
||||
badgeLabel = null,
|
||||
signingKey = "key",
|
||||
contactWebsite = "https://example.org",
|
||||
contactDiscord = null,
|
||||
isLegacy = false,
|
||||
extensionListUrl = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import eu.kanade.tachiyomi.util.lang.Hash.md5
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import tachiyomi.core.common.preference.InMemoryPreferenceStore
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
|
||||
class DownloadProviderTest {
|
||||
|
||||
private fun provider(disallowNonAscii: Boolean): DownloadProvider {
|
||||
val preferences = LibraryPreferences(InMemoryPreferenceStore())
|
||||
preferences.disallowNonAsciiFilenames.set(disallowNonAscii)
|
||||
preferences.enableChapterNameHash.set(true)
|
||||
return DownloadProvider(mockk(relaxed = true), mockk(relaxed = true), preferences)
|
||||
}
|
||||
|
||||
private fun dirNameBefore0210(name: String, scanlator: String?, url: String, disallowNonAscii: Boolean): String {
|
||||
val dirName = if (scanlator.isNullOrBlank()) name else "${scanlator}_$name"
|
||||
return DiskUtil.buildValidFilename(dirName, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAscii) +
|
||||
"_" + md5(url).take(6)
|
||||
}
|
||||
|
||||
private fun dirName0210(name: String, scanlator: String?, url: String, disallowNonAscii: Boolean): String {
|
||||
return DiskUtil.buildValidFilename(scanlator!!) + "_" +
|
||||
DiskUtil.buildValidFilename(name, DiskUtil.MAX_FILE_NAME_BYTES - 11, disallowNonAscii) +
|
||||
"_" + md5(url).take(6)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("chapters")
|
||||
fun `finds chapters downloaded before 0_21_0 and names new ones the same`(
|
||||
name: String,
|
||||
scanlator: String?,
|
||||
disallowNonAscii: Boolean,
|
||||
) {
|
||||
val provider = provider(disallowNonAscii)
|
||||
val expected = dirNameBefore0210(name, scanlator, URL, disallowNonAscii)
|
||||
|
||||
provider.getValidChapterDirNames(name, scanlator, URL) shouldContain expected
|
||||
provider.getChapterDirName(name, scanlator, URL) shouldBe expected
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("chapters")
|
||||
fun `finds chapters downloaded by 0_21_0 and 0_21_1`(name: String, scanlator: String?, disallowNonAscii: Boolean) {
|
||||
if (scanlator.isNullOrBlank()) return
|
||||
|
||||
provider(disallowNonAscii).getValidChapterDirNames(name, scanlator, URL) shouldContain
|
||||
dirName0210(name, scanlator, URL, disallowNonAscii)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val URL = "/chapter/1"
|
||||
|
||||
@JvmStatic
|
||||
fun chapters() = listOf(
|
||||
Arguments.of("Chapter 1", "Group", false),
|
||||
Arguments.of(" Chapter 1", "Group", false),
|
||||
Arguments.of(".Chapter 1", "Group", false),
|
||||
Arguments.of("Chapter 1", "Group ", false),
|
||||
Arguments.of("Chapter 1", "A/B ", false),
|
||||
Arguments.of("Chapter ".repeat(40), "Group", false),
|
||||
Arguments.of("Chapter 1", "翻訳グループ", true),
|
||||
Arguments.of(" Chapter 1", null, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
+45
-148
@@ -3,34 +3,25 @@ package eu.kanade.tachiyomi.network.interceptor
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.RenderProcessGoneDetail
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.ScriptHandler
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import eu.kanade.tachiyomi.network.AndroidCookieJar
|
||||
import eu.kanade.tachiyomi.util.system.isOutdated
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import logcat.LogPriority
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
|
||||
class CloudflareInterceptor(
|
||||
private val context: Context,
|
||||
@@ -41,18 +32,6 @@ class CloudflareInterceptor(
|
||||
|
||||
private val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
// Requests to a host share its lock while a challenge is solved under it alone, so concurrent requests wait for
|
||||
// one WebView instead of each opening their own
|
||||
private val hostLocks = ConcurrentHashMap<String, ReentrantReadWriteLock>()
|
||||
|
||||
private val listenerScript = """
|
||||
addEventListener("message", ({data}) => {
|
||||
if (data?.source === "cloudflare-challenge") {
|
||||
mihon?.postMessage(data.event);
|
||||
}
|
||||
})
|
||||
""".trimIndent()
|
||||
|
||||
override fun shouldIntercept(response: Response): Boolean {
|
||||
// Check if Cloudflare anti-bot is on
|
||||
// Checking the cf-mitigated header is the official way to detect a Cloudflare challenge:
|
||||
@@ -60,50 +39,19 @@ class CloudflareInterceptor(
|
||||
return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val lock = hostLocks.computeIfAbsent(request.url.host) { ReentrantReadWriteLock() }
|
||||
|
||||
val (response, clearance) = lock.read {
|
||||
val response = chain.proceed(request)
|
||||
if (!shouldIntercept(response)) return response
|
||||
response to clearance(request.url)
|
||||
}
|
||||
|
||||
// write releases the read hold of a request made from within another to the same host, like an extension
|
||||
// refreshing a token, which would otherwise wait on itself
|
||||
lock.write {
|
||||
// Another request may have solved the challenge while this one waited
|
||||
if (isCleared(request.url, clearance)) {
|
||||
response.close()
|
||||
} else {
|
||||
if (!prepareWebView()) return response
|
||||
solve(request, response, clearance)
|
||||
}
|
||||
}
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
|
||||
solve(request, response, clearance(request.url))
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun clearance(url: HttpUrl): String? {
|
||||
return cookieManager.get(url).firstOrNull { it.name == "cf_clearance" }?.value
|
||||
}
|
||||
|
||||
private fun isCleared(url: HttpUrl, previousClearance: String?): Boolean {
|
||||
val clearance = clearance(url)
|
||||
return !clearance.isNullOrBlank() && clearance != previousClearance
|
||||
}
|
||||
|
||||
private fun solve(request: Request, response: Response, clearance: String?) {
|
||||
override fun intercept(
|
||||
chain: Interceptor.Chain,
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Response {
|
||||
try {
|
||||
response.close()
|
||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||
resolveWithWebView(request, clearance)
|
||||
val oldCookie = cookieManager.get(request.url)
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
resolveWithWebView(request, oldCookie)
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
@@ -115,84 +63,64 @@ class CloudflareInterceptor(
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun resolveWithWebView(originalRequest: Request, originalClearance: String?) {
|
||||
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
|
||||
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||
// OkHttp doesn't support asynchronous interceptors.
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webview: WebView? = null
|
||||
var listenerScriptHandler: ScriptHandler? = null
|
||||
val useIsolatedWorld = WebViewFeature.isFeatureSupported(WebViewFeature.JS_INJECTION_IN_FRAME_AND_WORLD)
|
||||
|
||||
var challengeFound = false
|
||||
var cloudflareBypassed = false
|
||||
var challengeFailed = false
|
||||
var isWebViewOutdated = false
|
||||
var isRenderProcessGone = false
|
||||
|
||||
val origRequestUrl = originalRequest.url.toString()
|
||||
// A WebView can only load a page with GET, and loading the url of another method usually reaches an
|
||||
// endpoint that answers GET without a challenge to solve. The clearance covers the whole site, so it's
|
||||
// earned on the site's root page instead.
|
||||
val challengeUrl = if (originalRequest.method == "GET") {
|
||||
origRequestUrl
|
||||
} else {
|
||||
originalRequest.url.newBuilder().encodedPath("/").query(null).fragment(null).build().toString()
|
||||
}
|
||||
val headers = parseHeaders(originalRequest.headers)
|
||||
|
||||
executor.execute {
|
||||
webview = createWebView(originalRequest)
|
||||
|
||||
fun onChallengeEvent(event: String) {
|
||||
when (event) {
|
||||
// The challenge cannot be solved non-interactively, abort.
|
||||
"interactiveBegin" -> latch.countDown()
|
||||
"fail" -> {
|
||||
challengeFailed = true
|
||||
webview.addJavascriptInterface(
|
||||
object {
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun interactiveDetected() {
|
||||
// The challenge cannot be solved non-interactively, abort.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useIsolatedWorld) {
|
||||
// An isolated world keeps the bridge out of the page's reach
|
||||
val world = WebViewCompat.getExecutionWorld(webview, "mihon")
|
||||
val origins = setOf(originalRequest.url.origin())
|
||||
WebViewCompat.addWebMessageListener(webview, "mihon", origins, world) { _, message, _, isMainFrame, _ ->
|
||||
if (isMainFrame) message.data?.let(::onChallengeEvent)
|
||||
}
|
||||
listenerScriptHandler = WebViewCompat.addJavaScriptOnEvent(
|
||||
webview,
|
||||
listenerScript,
|
||||
WebViewCompat.INJECTION_EVENT_DOCUMENT_START,
|
||||
origins,
|
||||
world,
|
||||
)
|
||||
} else {
|
||||
webview.addJavascriptInterface(
|
||||
object {
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun postMessage(event: String) = onChallengeEvent(event)
|
||||
},
|
||||
"mihon",
|
||||
)
|
||||
}
|
||||
},
|
||||
"mihon",
|
||||
)
|
||||
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) {
|
||||
fun isCloudFlareBypassed(): Boolean {
|
||||
return cookieManager.get(origRequestUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
.let { it != null && it != oldCookie }
|
||||
}
|
||||
|
||||
if (isCloudFlareBypassed()) {
|
||||
cloudflareBypassed = true
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
if (url == challengeUrl) {
|
||||
if (url == origRequestUrl) {
|
||||
if (!challengeFound) {
|
||||
// The first request didn't return the challenge, abort.
|
||||
latch.countDown()
|
||||
} else if (!useIsolatedWorld) {
|
||||
view.evaluateJavascript(listenerScript, null)
|
||||
} else {
|
||||
// Listen for an interactiveBegin event
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
addEventListener("message", ({data}) => {
|
||||
if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") {
|
||||
mihon.interactiveDetected();
|
||||
}
|
||||
})
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,52 +140,26 @@ class CloudflareInterceptor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||||
// Not handling it kills the app, failing the bypass is enough
|
||||
logcat(LogPriority.ERROR) {
|
||||
"WebView renderer gone during Cloudflare bypass: crashed=${detail.didCrash()}, " +
|
||||
"priority=${detail.rendererPriorityAtExit()}, url=$origRequestUrl"
|
||||
}
|
||||
isRenderProcessGone = true
|
||||
latch.countDown()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
webview.loadUrl(challengeUrl, headers)
|
||||
webview.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
|
||||
executor.execute {
|
||||
// Only destroy can be called on a WebView whose renderer is gone
|
||||
if (!cloudflareBypassed && !isRenderProcessGone) {
|
||||
if (!cloudflareBypassed) {
|
||||
isWebViewOutdated = webview?.isOutdated() == true
|
||||
}
|
||||
|
||||
webview?.run {
|
||||
if (!isRenderProcessGone) {
|
||||
if (useIsolatedWorld) {
|
||||
WebViewCompat.removeWebMessageListener(
|
||||
this,
|
||||
WebViewCompat.getExecutionWorld(this, "mihon"),
|
||||
"mihon",
|
||||
)
|
||||
} else {
|
||||
removeJavascriptInterface("mihon")
|
||||
}
|
||||
listenerScriptHandler?.remove()
|
||||
stopLoading()
|
||||
}
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Throw exception if we failed to bypass Cloudflare
|
||||
if (!cloudflareBypassed) {
|
||||
// A clearance from a failed challenge would only be rejected again
|
||||
cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0)
|
||||
// Prompt user to update WebView if it seems too outdated
|
||||
if (isWebViewOutdated) {
|
||||
context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG)
|
||||
@@ -268,11 +170,6 @@ class CloudflareInterceptor(
|
||||
}
|
||||
}
|
||||
|
||||
// An origin rule without a port only matches the scheme's default one
|
||||
private fun HttpUrl.origin(): String {
|
||||
return if (port == HttpUrl.defaultPort(scheme)) "$scheme://$host" else "$scheme://$host:$port"
|
||||
}
|
||||
|
||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||
|
||||
|
||||
+3
-11
@@ -59,23 +59,15 @@ abstract class WebViewInterceptor(
|
||||
return response
|
||||
}
|
||||
|
||||
if (!prepareWebView()) return response
|
||||
|
||||
return intercept(chain, request, response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a WebView can be used, initializing it if so.
|
||||
*/
|
||||
protected fun prepareWebView(): Boolean {
|
||||
if (!WebViewUtil.supportsWebView(context)) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
|
||||
}
|
||||
return false
|
||||
return response
|
||||
}
|
||||
initWebView
|
||||
return true
|
||||
|
||||
return intercept(chain, request, response)
|
||||
}
|
||||
|
||||
fun parseHeaders(headers: Headers): Map<String, String> {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import javax.microedition.khronos.egl.EGL10
|
||||
import javax.microedition.khronos.egl.EGLConfig
|
||||
import javax.microedition.khronos.egl.EGLContext
|
||||
import kotlin.math.max
|
||||
|
||||
object GLUtil {
|
||||
val DEVICE_TEXTURE_LIMIT: Int by lazy {
|
||||
// Get EGL Display
|
||||
val egl = EGLContext.getEGL() as EGL10
|
||||
val display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY)
|
||||
|
||||
// Initialise
|
||||
val version = IntArray(2)
|
||||
egl.eglInitialize(display, version)
|
||||
|
||||
// Query total number of configurations
|
||||
val totalConfigurations = IntArray(1)
|
||||
egl.eglGetConfigs(display, null, 0, totalConfigurations)
|
||||
|
||||
// Query actual list configurations
|
||||
val configurationsList = arrayOfNulls<EGLConfig>(totalConfigurations[0])
|
||||
egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations)
|
||||
|
||||
val textureSize = IntArray(1)
|
||||
var maximumTextureSize = 0
|
||||
|
||||
// Iterate through all the configurations to located the maximum texture size
|
||||
for (i in 0..<totalConfigurations[0]) {
|
||||
// Only need to check for width since opengl textures are always squared
|
||||
egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize)
|
||||
|
||||
// Keep track of the maximum texture size
|
||||
if (maximumTextureSize < textureSize[0]) maximumTextureSize = textureSize[0]
|
||||
}
|
||||
|
||||
// Release
|
||||
egl.eglTerminate(display)
|
||||
|
||||
// Return largest texture size found (after making it a multiplier of [Multiplier]), or default
|
||||
max(maximumTextureSize, SAFE_TEXTURE_LIMIT)
|
||||
}
|
||||
|
||||
const val SAFE_TEXTURE_LIMIT: Int = 2048
|
||||
|
||||
val CUSTOM_TEXTURE_LIMIT_OPTIONS: List<Int> by lazy {
|
||||
val steps = DEVICE_TEXTURE_LIMIT / MULTIPLIER
|
||||
buildList(steps) {
|
||||
add(DEVICE_TEXTURE_LIMIT)
|
||||
for (step in steps downTo 2) {
|
||||
val value = step * MULTIPLIER
|
||||
if (value >= DEVICE_TEXTURE_LIMIT) continue
|
||||
add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MULTIPLIER: Int = 1024
|
||||
@@ -21,11 +21,13 @@ import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import ca.mpreg.imagedecoder.ImageDecoder
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.decoder.Format
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import java.io.InputStream
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
@@ -47,20 +49,17 @@ object ImageUtil {
|
||||
|
||||
fun findImageType(stream: InputStream): ImageType? {
|
||||
return try {
|
||||
val format = ImageDecoder.open(stream).use { dec -> dec.format }
|
||||
when (format) {
|
||||
ImageDecoder.Format.JPEG -> ImageType.JPEG
|
||||
ImageDecoder.Format.PNG -> ImageType.PNG
|
||||
ImageDecoder.Format.WEBP -> ImageType.WEBP
|
||||
ImageDecoder.Format.GIF -> ImageType.GIF
|
||||
ImageDecoder.Format.AVIF -> ImageType.AVIF
|
||||
ImageDecoder.Format.HEIF -> ImageType.HEIF
|
||||
ImageDecoder.Format.JXL -> ImageType.JXL
|
||||
ImageDecoder.Format.JP2 -> ImageType.JP2
|
||||
when (getImageType(stream)?.format) {
|
||||
Format.Avif -> ImageType.AVIF
|
||||
Format.Gif -> ImageType.GIF
|
||||
Format.Heif -> ImageType.HEIF
|
||||
Format.Jpeg -> ImageType.JPEG
|
||||
Format.Jxl -> ImageType.JXL
|
||||
Format.Png -> ImageType.PNG
|
||||
Format.Webp -> ImageType.WEBP
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "findImageType: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -72,22 +71,38 @@ object ImageUtil {
|
||||
|
||||
fun isAnimatedAndSupported(source: BufferedSource): Boolean {
|
||||
return try {
|
||||
val type = findImageType(source.peek().inputStream()) ?: return false
|
||||
when (type) {
|
||||
ImageType.GIF -> true
|
||||
ImageType.WEBP, ImageType.HEIF -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
|
||||
ImageDecoder.open(source.peek().inputStream()).use { dec -> dec.pages > 1 }
|
||||
}
|
||||
|
||||
val type = getImageType(source.peek().inputStream()) ?: return false
|
||||
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||
when (type.format) {
|
||||
Format.Gif -> true
|
||||
// Animated WebP on Android 9+
|
||||
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
// Animated Heif on Android 11+
|
||||
Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "isAnimatedAndSupported: ${e.message}" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
|
||||
val bytes = ByteArray(32)
|
||||
|
||||
val length = if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
if (length == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ImageDecoder.findType(bytes)
|
||||
}
|
||||
|
||||
enum class ImageType(val mime: String, val extension: String) {
|
||||
AVIF("image/avif", "avif"),
|
||||
GIF("image/gif", "gif"),
|
||||
@@ -96,8 +111,6 @@ object ImageUtil {
|
||||
JXL("image/jxl", "jxl"),
|
||||
PNG("image/png", "png"),
|
||||
WEBP("image/webp", "webp"),
|
||||
JP2("image/jp2", "jp2"),
|
||||
JPX("image/jpx", "jpx"),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,24 +312,30 @@ object ImageUtil {
|
||||
val bottomOffset = topOffset + splitHeight
|
||||
}
|
||||
|
||||
fun canUseHardwareBitmap(bitmap: Bitmap): Boolean {
|
||||
return canUseHardwareBitmap(bitmap.width, bitmap.height)
|
||||
}
|
||||
|
||||
fun canUseHardwareBitmap(imageSource: BufferedSource): Boolean {
|
||||
return with(extractImageOptions(imageSource)) {
|
||||
canUseHardwareBitmap(outWidth, outHeight)
|
||||
}
|
||||
}
|
||||
|
||||
var hardwareBitmapThreshold: Int = GLUtil.SAFE_TEXTURE_LIMIT
|
||||
|
||||
private fun canUseHardwareBitmap(width: Int, height: Int): Boolean {
|
||||
if (HARDWARE_BITMAP_UNSUPPORTED) return false
|
||||
return maxOf(width, height) <= hardwareBitmapThreshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Algorithm for determining what background to accompany a comic/manga page
|
||||
*/
|
||||
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
|
||||
val image = try {
|
||||
ImageDecoder.open(imageStream).use { it.decodeNext() to it.isHdr }.let { (res, hdr) ->
|
||||
res.use {
|
||||
val config = if (hdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888
|
||||
createBitmap(res.width, res.height, config).also { bitmap ->
|
||||
res.image.rewind()
|
||||
bitmap.copyPixelsFromBuffer(res.image)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" }
|
||||
null
|
||||
}
|
||||
val decoder = ImageDecoder.newInstance(imageStream)
|
||||
val image = decoder?.decode()
|
||||
decoder?.recycle()
|
||||
|
||||
if (image == null) return Color.WHITE.toDrawable()
|
||||
return try {
|
||||
@@ -564,6 +583,121 @@ object ImageUtil {
|
||||
}
|
||||
|
||||
private val optimalImageHeight = getDisplayMaxHeightInPx * 2
|
||||
|
||||
/**
|
||||
* Taken from Coil
|
||||
* (https://github.com/coil-kt/coil/blob/1674d3516f061aeacbe749a435b1924f9648fd41/coil-core/src/androidMain/kotlin/coil3/util/hardwareBitmaps.kt)
|
||||
* ---
|
||||
* Maintains a list of devices with broken/incomplete/unstable hardware bitmap implementations.
|
||||
*
|
||||
* Model names are retrieved from
|
||||
* [Google's official device list](https://support.google.com/googleplay/answer/1727131?hl=en).
|
||||
*
|
||||
*/
|
||||
val HARDWARE_BITMAP_UNSUPPORTED = when (Build.VERSION.SDK_INT) {
|
||||
26 -> run {
|
||||
val model = Build.MODEL ?: return@run false
|
||||
|
||||
// Samsung Galaxy (ALL)
|
||||
if (model.removePrefix("SAMSUNG-").startsWith("SM-")) return@run true
|
||||
|
||||
val device = Build.DEVICE ?: return@run false
|
||||
|
||||
return@run device in arrayOf(
|
||||
"nora", "nora_8917", "nora_8917_n", // Moto E5
|
||||
"james", "rjames_f", "rjames_go", "pettyl", // Moto E5 Play
|
||||
"hannah", "ahannah", "rhannah", // Moto E5 Plus
|
||||
|
||||
"ali", "ali_n", // Moto G6
|
||||
"aljeter", "aljeter_n", "jeter", // Moto G6 Play
|
||||
"evert", "evert_n", "evert_nt", // Moto G6 Plus
|
||||
|
||||
"G3112", "G3116", "G3121", "G3123", "G3125", // Xperia XA1
|
||||
"G3412", "G3416", "G3421", "G3423", "G3426", // Xperia XA1 Plus
|
||||
"G3212", "G3221", "G3223", "G3226", // Xperia XA1 Ultra
|
||||
|
||||
"BV6800Pro", // BlackView BV6800Pro
|
||||
"CatS41", // Cat S41
|
||||
"Hi9Pro", // CHUWI Hi9 Pro
|
||||
"manning", // Lenovo K8 Note
|
||||
"N5702L", // NUU Mobile G3
|
||||
)
|
||||
}
|
||||
|
||||
27 -> run {
|
||||
val device = Build.DEVICE ?: return@run false
|
||||
|
||||
return@run device in arrayOf(
|
||||
"mcv1s", // LG Tribute Empire
|
||||
"mcv3", // LG K11
|
||||
"mcv5a", // LG Q7
|
||||
"mcv7a", // LG Stylo 4
|
||||
|
||||
"A30ATMO", // T-Mobile REVVL 2
|
||||
"A70AXLTMO", // T-Mobile REVVL 2 PLUS
|
||||
|
||||
"A3A_8_4G_TMO", // Alcatel 9027W
|
||||
"Edison_CKT", // Alcatel ONYX
|
||||
"EDISON_TF", // Alcatel TCL XL2
|
||||
"FERMI_TF", // Alcatel A501DL
|
||||
"U50A_ATT", // Alcatel TETRA
|
||||
"U50A_PLUS_ATT", // Alcatel 5059R
|
||||
"U50A_PLUS_TF", // Alcatel TCL LX
|
||||
"U50APLUSTMO", // Alcatel 5059Z
|
||||
"U5A_PLUS_4G", // Alcatel 1X
|
||||
|
||||
"RCT6513W87DK5e", // RCA Galileo Pro
|
||||
"RCT6873W42BMF9A", // RCA Voyager
|
||||
"RCT6A03W13", // RCA 10 Viking
|
||||
"RCT6B03W12", // RCA Atlas 10 Pro
|
||||
"RCT6B03W13", // RCA Atlas 10 Pro+
|
||||
"RCT6T06E13", // RCA Artemis 10
|
||||
|
||||
"A3_Pro", // Umidigi A3 Pro
|
||||
"One", // Umidigi One
|
||||
"One_Max", // Umidigi One Max
|
||||
"One_Pro", // Umidigi One Pro
|
||||
"Z2", // Umidigi Z2
|
||||
"Z2_PRO", // Umidigi Z2 Pro
|
||||
|
||||
"Armor_3", // Ulefone Armor 3
|
||||
"Armor_6", // Ulefone Armor 6
|
||||
|
||||
"Blackview", // Blackview BV6000
|
||||
"BV9500", // Blackview BV9500
|
||||
"BV9500Pro", // Blackview BV9500Pro
|
||||
|
||||
"A6L-C", // Nuu A6L-C
|
||||
"N5002LA", // Nuu A7L
|
||||
"N5501LA", // Nuu A5L
|
||||
|
||||
"Power_2_Pro", // Leagoo Power 2 Pro
|
||||
"Power_5", // Leagoo Power 5
|
||||
"Z9", // Leagoo Z9
|
||||
|
||||
"V0310WW", // Blu VIVO VI+
|
||||
"V0330WW", // Blu VIVO XI
|
||||
|
||||
"A3", // BenQ A3
|
||||
"ASUS_X018_4", // Asus ZenFone Max Plus M1 (ZB570TL)
|
||||
"C210AE", // Wiko Life
|
||||
"fireball", // DROID Incredible 4G LTE
|
||||
"ILA_X1", // iLA X1
|
||||
"Infinix-X605_sprout", // Infinix NOTE 5 Stylus
|
||||
"j7maxlte", // Samsung Galaxy J7 Max
|
||||
"KING_KONG_3", // Cubot King Kong 3
|
||||
"M10500", // Packard Bell M10500
|
||||
"S70", // Altice ALTICE S70
|
||||
"S80Lite", // Doogee S80Lite
|
||||
"SGINO6", // SGiNO 6
|
||||
"st18c10bnn", // Barnes and Noble BNTV650
|
||||
"TECNO-CA8", // Tecno CAMON X Pro,
|
||||
"SHIFT6m", // SHIFT 6m
|
||||
)
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
val getDisplayMaxHeightInPx: Int
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
package mihon.data.database
|
||||
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.domain.database.repository.DatabaseRepository
|
||||
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DatabaseRepositoryImpl(
|
||||
// Lazy since the driver reports its migration state here
|
||||
private val driver: Lazy<SqlDriver>,
|
||||
) : DatabaseRepository {
|
||||
class DatabaseRepositoryImpl : DatabaseRepository {
|
||||
|
||||
override val isMigrating = MutableStateFlow(false)
|
||||
|
||||
override suspend fun awaitOpen() {
|
||||
withContext(Dispatchers.IO) {
|
||||
driver.value.executeQuery(null, "SELECT 1", { QueryResult.Value(Unit) }, 0).await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +50,6 @@ class CategoryRepositoryImpl(
|
||||
.subscribeToList()
|
||||
}
|
||||
|
||||
override suspend fun getCategoriesByMangaIds(mangaIds: List<Long>): Map<Long, List<Category>> {
|
||||
return database.categoryQueries
|
||||
.getCategoriesByMangaIds(mangaIds) { mangaId, id, name, order, flags ->
|
||||
mangaId to mapCategory(id, name, order, flags)
|
||||
}
|
||||
.awaitAsList()
|
||||
.groupBy({ it.first }, { it.second })
|
||||
}
|
||||
|
||||
override suspend fun insert(category: NewCategory) {
|
||||
database.categoryQueries.insert(name = category.name, flags = category.flags)
|
||||
}
|
||||
|
||||
@@ -113,13 +113,6 @@ class ChapterRepositoryImpl(
|
||||
.awaitAsList()
|
||||
}
|
||||
|
||||
override suspend fun getChaptersByMangaIds(mangaIds: List<Long>): Map<Long, List<Chapter>> {
|
||||
return database.chapterQueries
|
||||
.getChaptersByMangaIds(mangaIds, ::mapChapter)
|
||||
.awaitAsList()
|
||||
.groupBy { it.mangaId }
|
||||
}
|
||||
|
||||
override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary {
|
||||
return database.chapterQueries
|
||||
.getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber ->
|
||||
|
||||
@@ -48,15 +48,6 @@ class HistoryRepositoryImpl(
|
||||
.awaitAsList()
|
||||
}
|
||||
|
||||
override suspend fun getHistoryByMangaIds(mangaIds: List<Long>): Map<Long, List<History>> {
|
||||
return database.historyQueries
|
||||
.getHistoryByMangaIds(mangaIds) { mangaId, id, chapterId, readAt, readDuration ->
|
||||
mangaId to HistoryMapper.mapHistory(id, chapterId, readAt, readDuration)
|
||||
}
|
||||
.awaitAsList()
|
||||
.groupBy({ it.first }, { it.second })
|
||||
}
|
||||
|
||||
override suspend fun resetHistory(historyId: Long) {
|
||||
try {
|
||||
database.historyQueries.resetHistoryById(historyId)
|
||||
|
||||
@@ -144,13 +144,6 @@ class MangaRepositoryImpl(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override suspend fun getExcludedScanlatorsByMangaIds(mangaIds: List<Long>): Map<Long, List<String>> {
|
||||
return database.excluded_scanlatorQueries
|
||||
.getExcludedScanlatorsByMangaIds(mangaIds)
|
||||
.awaitAsList()
|
||||
.groupBy({ it.manga_id }, { it.scanlator })
|
||||
}
|
||||
|
||||
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
|
||||
return database.excluded_scanlatorQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
|
||||
@@ -31,13 +31,6 @@ class TrackRepositoryImpl(
|
||||
.awaitAsList()
|
||||
}
|
||||
|
||||
override suspend fun getTracksByMangaIds(mangaIds: List<Long>): Map<Long, List<Track>> {
|
||||
return database.manga_trackQueries
|
||||
.getTracksByMangaIds(mangaIds, TrackMapper::mapTrack)
|
||||
.awaitAsList()
|
||||
.groupBy { it.mangaId }
|
||||
}
|
||||
|
||||
override suspend fun getTracks(): List<Track> {
|
||||
return database.manga_trackQueries
|
||||
.getTracks(TrackMapper::mapTrack)
|
||||
|
||||
@@ -34,14 +34,6 @@ ON C.id = MC.category_id
|
||||
WHERE MC.manga_id = :mangaId
|
||||
ORDER BY C.`order`;
|
||||
|
||||
getCategoriesByMangaIds:
|
||||
SELECT MC.manga_id, C.id, C.name, C.`order`, C.flags
|
||||
FROM category C
|
||||
JOIN manga_category MC
|
||||
ON C.id = MC.category_id
|
||||
WHERE MC.manga_id IN :mangaIds
|
||||
ORDER BY MC.manga_id, C.`order`;
|
||||
|
||||
insert:
|
||||
-- The position is taken inside the insert, so two categories created at once cannot claim the same one
|
||||
INSERT INTO category(name, `order`, flags)
|
||||
|
||||
@@ -48,16 +48,6 @@ AND (
|
||||
OR ES.scanlator IS NULL
|
||||
);
|
||||
|
||||
getChaptersByMangaIds:
|
||||
-- The unique index's order, which getChaptersByMangaId returns too, so backups keep their chapter order
|
||||
SELECT
|
||||
id, manga_id, remote_url, remote_name, remote_scanlator, remote_chapter_number,
|
||||
remote_date_upload, remote_order, remote_memo, user_read, user_bookmark,
|
||||
user_last_page_read, state_date_fetch
|
||||
FROM chapter
|
||||
WHERE manga_id IN :mangaIds
|
||||
ORDER BY manga_id, remote_url;
|
||||
|
||||
getChapterSummaryByMangaId:
|
||||
SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber
|
||||
FROM chapter
|
||||
|
||||
@@ -21,9 +21,3 @@ getExcludedScanlatorsByMangaId:
|
||||
SELECT scanlator
|
||||
FROM excluded_scanlator
|
||||
WHERE manga_id = :mangaId;
|
||||
|
||||
getExcludedScanlatorsByMangaIds:
|
||||
SELECT manga_id, scanlator
|
||||
FROM excluded_scanlator
|
||||
WHERE manga_id IN :mangaIds
|
||||
ORDER BY manga_id, scanlator;
|
||||
|
||||
@@ -20,12 +20,6 @@ SELECT id, chapter_id, read_at, read_duration
|
||||
FROM history
|
||||
WHERE manga_id = :mangaId;
|
||||
|
||||
getHistoryByMangaIds:
|
||||
SELECT manga_id, id, chapter_id, read_at, read_duration
|
||||
FROM history
|
||||
WHERE manga_id IN :mangaIds
|
||||
ORDER BY manga_id, id;
|
||||
|
||||
resetHistoryById:
|
||||
UPDATE history
|
||||
SET read_at = NULL
|
||||
|
||||
@@ -48,14 +48,6 @@ SELECT
|
||||
FROM manga_track
|
||||
WHERE manga_id = :mangaId;
|
||||
|
||||
getTracksByMangaIds:
|
||||
SELECT
|
||||
id, manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
|
||||
total_chapters, status, score, start_date, finish_date, private
|
||||
FROM manga_track
|
||||
WHERE manga_id IN :mangaIds
|
||||
ORDER BY manga_id, tracker_id;
|
||||
|
||||
upsert:
|
||||
INSERT INTO manga_track(
|
||||
manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read,
|
||||
|
||||
@@ -9,9 +9,8 @@ DROP INDEX library_favorite_index;
|
||||
|
||||
ALTER TABLE mangas ADD COLUMN favorite_at INTEGER;
|
||||
|
||||
-- favorite_modified_at is no fallback for a missing date_added: every manga update rewrote it
|
||||
UPDATE mangas
|
||||
SET favorite_at = coalesce(date_added, 0)
|
||||
SET favorite_at = coalesce(nullif(date_added, 0), favorite_modified_at * 1000, 0)
|
||||
WHERE favorite = 1;
|
||||
|
||||
ALTER TABLE mangas DROP COLUMN favorite;
|
||||
|
||||
@@ -4,81 +4,15 @@ import kotlin.String;
|
||||
import kotlin.collections.List;
|
||||
import kotlinx.serialization.json.JsonObject;
|
||||
|
||||
-- Every view reads `mangas`, so they are dropped here and recreated against the new tables at the end
|
||||
DROP VIEW historyView;
|
||||
DROP VIEW libraryView;
|
||||
DROP VIEW updatesView;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
-- damaged indexes
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- Some databases hold rows their unique constraints should have rejected, which a damaged index explains. A
|
||||
-- write to a table also writes its indexes and fails on a damaged one, and a read through one can miss rows,
|
||||
-- so each table changed in place below is first copied from its rows alone, leaving its indexes behind.
|
||||
CREATE TABLE mangas_copy AS
|
||||
SELECT
|
||||
_id, source, url, artist, author, description, genre, title, status, thumbnail_url, last_update, next_update,
|
||||
initialized, viewer, chapter_flags, cover_last_modified, update_strategy, calculate_interval, notes, memo,
|
||||
favorite_at
|
||||
FROM mangas NOT INDEXED;
|
||||
DROP TABLE mangas;
|
||||
ALTER TABLE mangas_copy RENAME TO mangas;
|
||||
|
||||
CREATE TABLE chapters_copy AS
|
||||
SELECT
|
||||
_id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch,
|
||||
date_upload, memo
|
||||
FROM chapters NOT INDEXED;
|
||||
DROP TABLE chapters;
|
||||
ALTER TABLE chapters_copy RENAME TO chapters;
|
||||
|
||||
CREATE TABLE history_copy AS
|
||||
SELECT _id, chapter_id, last_read, time_read
|
||||
FROM history NOT INDEXED;
|
||||
DROP TABLE history;
|
||||
ALTER TABLE history_copy RENAME TO history;
|
||||
|
||||
CREATE TABLE mangas_categories_copy AS
|
||||
SELECT _id, manga_id, category_id
|
||||
FROM mangas_categories NOT INDEXED;
|
||||
DROP TABLE mangas_categories;
|
||||
ALTER TABLE mangas_categories_copy RENAME TO mangas_categories;
|
||||
|
||||
CREATE TABLE manga_sync_copy AS
|
||||
SELECT
|
||||
_id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score,
|
||||
remote_url, start_date, finish_date, private
|
||||
FROM manga_sync NOT INDEXED;
|
||||
DROP TABLE manga_sync;
|
||||
ALTER TABLE manga_sync_copy RENAME TO manga_sync;
|
||||
|
||||
CREATE TABLE excluded_scanlators_copy AS
|
||||
SELECT manga_id, scanlator
|
||||
FROM excluded_scanlators NOT INDEXED;
|
||||
DROP TABLE excluded_scanlators;
|
||||
ALTER TABLE excluded_scanlators_copy RENAME TO excluded_scanlators;
|
||||
|
||||
-- Merging duplicate chapters looks their history up by chapter
|
||||
CREATE INDEX history_chapter_id_index ON history(chapter_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
-- orphans
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- Rows pointing at an entry, chapter or category that no longer exists were written while foreign keys weren't
|
||||
-- enforced. Nothing can reach them, and copied over they'd fail the foreign key check that ends the migration.
|
||||
DELETE FROM chapters WHERE manga_id NOT IN (SELECT _id FROM mangas);
|
||||
DELETE FROM history WHERE chapter_id NOT IN (SELECT _id FROM chapters);
|
||||
DELETE FROM mangas_categories
|
||||
WHERE manga_id NOT IN (SELECT _id FROM mangas) OR category_id NOT IN (SELECT _id FROM categories);
|
||||
DELETE FROM manga_sync WHERE manga_id NOT IN (SELECT _id FROM mangas);
|
||||
DELETE FROM excluded_scanlators WHERE manga_id NOT IN (SELECT _id FROM mangas);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
-- table - manga
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- Every view reads `mangas`, so they are dropped here and recreated against the new table below
|
||||
DROP VIEW historyView;
|
||||
DROP VIEW libraryView;
|
||||
DROP VIEW updatesView;
|
||||
|
||||
-- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are
|
||||
-- ranked with the ones in the library first, since that is the entry the user has, then by age. The first
|
||||
-- one stays, takes each detail it is missing from the first copy that has it, and takes over everything
|
||||
@@ -97,9 +31,6 @@ WHERE (source, url) IN (
|
||||
HAVING count(*) > 1
|
||||
);
|
||||
|
||||
-- Moving the chapters looks each one's entry up here, which without an index scans the whole table per chapter
|
||||
CREATE INDEX duplicate_mangas_id_index ON duplicate_mangas(_id);
|
||||
|
||||
UPDATE mangas
|
||||
SET favorite_at = merged.favorite_at,
|
||||
initialized = merged.initialized,
|
||||
@@ -379,7 +310,8 @@ CREATE INDEX manga_category_category_id_index ON manga_category(category_id);
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so
|
||||
-- both are renamed to say so. The chapter it belongs to is already unique, so the constraint indexes it.
|
||||
-- both are renamed to say so. The chapter it belongs to is already unique, which leaves
|
||||
-- `history_history_chapter_id_index` a prefix of that constraint.
|
||||
-- The table keeps its name, so the original is renamed aside and the new one built under
|
||||
-- the real name; renaming a table into place makes SQLite rewrite its stored definition,
|
||||
-- leaving it spelled differently from the same table on a fresh install. The views were
|
||||
@@ -401,14 +333,11 @@ CREATE TABLE history(
|
||||
-- Reaching the entry a row belongs to meant going through its chapter; it names the entry
|
||||
-- itself now, taken from that same chapter. A row whose chapter is already gone cannot name
|
||||
-- one and is dropped, which is what the foreign key would do to it once it is enforced again.
|
||||
-- Some databases hold more than one row for a chapter despite the old unique constraint, and the new one
|
||||
-- rejects them, so they merge the way the history of duplicate chapters does above.
|
||||
INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration)
|
||||
SELECT min(H._id), C.manga_id, H.chapter_id, max(H.last_read), sum(H.time_read)
|
||||
SELECT H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read
|
||||
FROM history_temp H
|
||||
JOIN chapter C
|
||||
ON C.id = H.chapter_id
|
||||
GROUP BY H.chapter_id;
|
||||
ON C.id = H.chapter_id;
|
||||
|
||||
DROP TABLE history_temp;
|
||||
|
||||
@@ -443,20 +372,19 @@ DROP TABLE excluded_scanlators;
|
||||
-- ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker.
|
||||
-- An entry that merged can leave two rows for the same tracker on the survivor, and an entry that didn't can
|
||||
-- still hold two despite the old unique constraint. The new one rejects both, so the one to keep is chosen
|
||||
-- first: one on a copy in the library, then the one furthest read, then the one on the copy ranked first, then
|
||||
-- the oldest. A track is taken whole rather than merged field by field, because two rows on the same tracker
|
||||
-- can point at different remote entries.
|
||||
-- An entry that merged can leave two rows for the same tracker on the survivor, which the unique constraint
|
||||
-- rejects, so the one to keep is chosen first: one on a copy in the library, then the one furthest read, then
|
||||
-- the one on the copy ranked first, then the oldest. A track is taken whole rather than merged field by field,
|
||||
-- because two rows on the same tracker can point at different remote entries.
|
||||
CREATE TEMP TABLE duplicate_tracks AS
|
||||
SELECT
|
||||
T._id,
|
||||
first_value(T._id) OVER (
|
||||
PARTITION BY coalesce(D.keep_id, T.manga_id), T.sync_id
|
||||
PARTITION BY D.keep_id, T.sync_id
|
||||
ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC
|
||||
) AS keep_track_id
|
||||
FROM manga_sync T
|
||||
LEFT JOIN duplicate_mangas D
|
||||
JOIN duplicate_mangas D
|
||||
ON D._id = T.manga_id;
|
||||
|
||||
DELETE FROM manga_sync
|
||||
|
||||
@@ -9,9 +9,4 @@ interface DatabaseRepository {
|
||||
* app update.
|
||||
*/
|
||||
val isMigrating: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Opens the database, migrating it if needed, and throws what made the migration fail.
|
||||
*/
|
||||
suspend fun awaitOpen()
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ interface CategoryRepository {
|
||||
|
||||
fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
|
||||
|
||||
suspend fun getCategoriesByMangaIds(mangaIds: List<Long>): Map<Long, List<Category>>
|
||||
|
||||
suspend fun insert(category: NewCategory)
|
||||
|
||||
suspend fun insertAll(categories: List<NewCategory>)
|
||||
|
||||
@@ -20,8 +20,6 @@ interface ChapterRepository {
|
||||
|
||||
suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List<Chapter>
|
||||
|
||||
suspend fun getChaptersByMangaIds(mangaIds: List<Long>): Map<Long, List<Chapter>>
|
||||
|
||||
suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary
|
||||
|
||||
suspend fun getScanlatorsByMangaId(mangaId: Long): List<String>
|
||||
|
||||
@@ -3,7 +3,6 @@ package tachiyomi.domain.chapter.service
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import java.util.Locale
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.round
|
||||
|
||||
@@ -78,9 +77,9 @@ object ChapterRecognition {
|
||||
private val prologue = Regex("""\b(?:prologue|one.?shot|pilot)\b""")
|
||||
|
||||
/**
|
||||
* Formats numbers without trailing zeros, with ASCII digits and a dot whatever the locale, e.g. 10.5, 3, 24.005
|
||||
* Formats numbers without trailing zeros and with a dot whatever the locale, e.g. 10.5, 3, 24.005
|
||||
*/
|
||||
private val formatter = DecimalFormat("#.###", DecimalFormatSymbols(Locale.ROOT))
|
||||
private val formatter = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
|
||||
|
||||
/**
|
||||
* The volume and chapter number recognized for a chapter, e.g. "10.5" or "19a", `null` when there is none.
|
||||
|
||||
@@ -15,8 +15,6 @@ interface HistoryRepository {
|
||||
|
||||
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
|
||||
|
||||
suspend fun getHistoryByMangaIds(mangaIds: List<Long>): Map<Long, List<History>>
|
||||
|
||||
suspend fun resetHistory(historyId: Long)
|
||||
|
||||
suspend fun resetHistoryByMangaId(mangaId: Long)
|
||||
|
||||
@@ -43,8 +43,6 @@ interface MangaRepository {
|
||||
|
||||
suspend fun getExcludedScanlators(mangaId: Long): Set<String>
|
||||
|
||||
suspend fun getExcludedScanlatorsByMangaIds(mangaIds: List<Long>): Map<Long, List<String>>
|
||||
|
||||
fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>>
|
||||
|
||||
suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>)
|
||||
|
||||
@@ -9,8 +9,6 @@ interface TrackRepository {
|
||||
|
||||
suspend fun getTracksByMangaId(mangaId: Long): List<Track>
|
||||
|
||||
suspend fun getTracksByMangaIds(mangaIds: List<Long>): Map<Long, List<Track>>
|
||||
|
||||
suspend fun getTracks(): List<Track>
|
||||
|
||||
fun getTracksAsFlow(): Flow<List<Track>>
|
||||
|
||||
@@ -15,8 +15,7 @@ androidx-coreSplashScreen = "1.2.0"
|
||||
androidx-glance = "1.2.0"
|
||||
androidx-interpolator = "1.0.0"
|
||||
androidx-lifecycle = "2.11.0"
|
||||
androidx-navigationEvent = "1.2.0"
|
||||
androidx-paging = "3.5.3"
|
||||
androidx-paging = "3.5.2"
|
||||
androidx-preference = "1.2.1"
|
||||
androidx-profileInstaller = "1.4.1"
|
||||
androidx-recyclerView = "1.4.0"
|
||||
@@ -42,7 +41,7 @@ firebase-bom = "35.0.0"
|
||||
firebase-crashlytics = "3.0.8"
|
||||
flexibleAdapter = "5.1.0"
|
||||
google-services = "4.5.0"
|
||||
image-decoder = "16"
|
||||
image-decoder = "e03b81e18a"
|
||||
injekt = "1.16.1"
|
||||
jsoup = "1.23.2"
|
||||
junit = "6.1.3"
|
||||
@@ -71,13 +70,12 @@ spotless = "8.10.4"
|
||||
sqldelight = "2.4.1"
|
||||
sqldelight-androidx-driver = "0.2.2"
|
||||
stringSimilarity = "0.1.0"
|
||||
subsamplingScaleImageView = "1b283e2bc4"
|
||||
subsamplingScaleImageView = "66e0db195d"
|
||||
swipe = "1.3.0"
|
||||
tapmoc = "0.4.2"
|
||||
unifile = "08f224c8f9"
|
||||
valkyrie = "0.5.2"
|
||||
voyager = "2.2.21-1.10.3"
|
||||
webgpuviewer = "52"
|
||||
xmlutil = "1.0.2"
|
||||
|
||||
[libraries]
|
||||
@@ -111,7 +109,6 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process",
|
||||
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-navigationEvent = { module = "androidx.navigationevent:navigationevent", version.ref = "androidx-navigationEvent" }
|
||||
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
|
||||
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
|
||||
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
|
||||
@@ -142,7 +139,7 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
|
||||
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
|
||||
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
|
||||
flexibleAdapter = { module = "eu.davidea:flexible-adapter", version.ref = "flexibleAdapter" }
|
||||
image-decoder = { module = "ca.mpreg:imagedecoder", version.ref = "image-decoder" }
|
||||
image-decoder = { module = "com.github.mihonapp:image-decoder", version.ref = "image-decoder" }
|
||||
injekt = { module = "uy.kohesive.injekt:injekt-core", version.ref = "injekt" }
|
||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
|
||||
@@ -192,14 +189,13 @@ sqldelight-async = { module = "app.cash.sqldelight:async-extensions", version.re
|
||||
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||
sqldelight-sqliteDialect338 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" }
|
||||
stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version.ref = "stringSimilarity" }
|
||||
subsamplingScaleImageView = { module = "com.github.mihonapp:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" }
|
||||
subsamplingScaleImageView = { module = "com.github.tachiyomiorg:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" }
|
||||
swipe = { module = "me.saket.swipe:swipe", version.ref = "swipe" }
|
||||
tapmoc-gradle = { module = "com.gradleup.tapmoc:tapmoc-gradle-plugin", version.ref = "tapmoc" }
|
||||
unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" }
|
||||
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
|
||||
voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
|
||||
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
|
||||
webgpuviewer = { module = "ca.mpreg:webgpuviewer", version.ref = "webgpuviewer" }
|
||||
xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core-android", version.ref = "xmlutil" }
|
||||
xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" }
|
||||
|
||||
|
||||
@@ -427,7 +427,9 @@
|
||||
<string name="pref_webtoon_vertical_navigator_on_left">Place vertical navigator on the left side</string>
|
||||
<string name="pref_show_reading_mode">Show reading mode</string>
|
||||
<string name="pref_show_reading_mode_summary">Briefly show current mode when reader is opened</string>
|
||||
<string name="pref_high_quality_renderer">Use high quality renderer</string>
|
||||
<string name="pref_hardware_bitmap_threshold">Custom hardware bitmap threshold</string>
|
||||
<string name="pref_hardware_bitmap_threshold_default">Default (%d)</string>
|
||||
<string name="pref_hardware_bitmap_threshold_summary">If reader loads a blank image incrementally reduce the threshold.\nSelected: %s</string>
|
||||
<string name="pref_display_profile">Custom display profile</string>
|
||||
<string name="pref_crop_borders">Crop borders</string>
|
||||
<string name="pref_custom_brightness">Custom brightness</string>
|
||||
@@ -517,34 +519,6 @@
|
||||
<string name="pref_low">Low</string>
|
||||
<string name="pref_lowest">Lowest</string>
|
||||
<string name="pref_webtoon_disable_zoom_out">Disable zoom out</string>
|
||||
<string name="webgpu_viewer">WebGPU</string>
|
||||
<string name="pref_transition_animation">Transition animation</string>
|
||||
<string name="pref_transition_animation_dual">Transition animation (dual)</string>
|
||||
<string name="transition_animation_basic">Basic</string>
|
||||
<string name="transition_animation_flip_left">Page flip (left)</string>
|
||||
<string name="transition_animation_flip_right">Page flip (right)</string>
|
||||
<string name="transition_animation_stack_left">Stack (left)</string>
|
||||
<string name="transition_animation_stack_right">Stack (right)</string>
|
||||
<string name="transition_animation_stack_up">Stack (up)</string>
|
||||
<string name="transition_animation_stack_down">Stack (down)</string>
|
||||
<string name="transition_animation_sphere">Sphere</string>
|
||||
<string name="transition_animation_cube_inside">Cube (Inside)</string>
|
||||
<string name="transition_animation_cube_outside">Cube (Outside)</string>
|
||||
<string name="transition_animation_fade">Fade</string>
|
||||
<string name="transition_animation_fade_white">Fade to white</string>
|
||||
<string name="transition_animation_none">None</string>
|
||||
<string name="transition_animation_flip">Page flip</string>
|
||||
<string name="pref_cutout_mode">Display cutout mode</string>
|
||||
<string name="pref_cutout_mode_dual">Display cutout mode (dual)</string>
|
||||
<string name="cutout_mode_ignore">Ignore</string>
|
||||
<string name="cutout_mode_avoid">Avoid</string>
|
||||
<string name="cutout_mode_shift">Shift</string>
|
||||
<string name="pref_dual_page_view">Dual page view</string>
|
||||
<string name="dual_page_view_never">Never</string>
|
||||
<string name="dual_page_view_always">Always</string>
|
||||
<string name="dual_page_view_wide">When wide</string>
|
||||
<string name="pref_continuous_minwidth">Min width</string>
|
||||
<string name="pref_continuous_gap">Gap</string>
|
||||
|
||||
<!-- Downloads section -->
|
||||
<string name="pref_category_delete_chapters">Delete chapters</string>
|
||||
@@ -1020,8 +994,6 @@
|
||||
<string name="information_webview_required">WebView is required for the app to function</string>
|
||||
<!-- Do not translate "WebView" -->
|
||||
<string name="information_webview_outdated">Please update the WebView app for better compatibility</string>
|
||||
<!-- Do not translate "WebView" -->
|
||||
<string name="information_webview_crashed">WebView stopped working and the page was closed</string>
|
||||
<string name="chapter_settings_updated">Updated default chapter settings</string>
|
||||
|
||||
<!-- Download Notification -->
|
||||
|
||||
+1
-1
@@ -300,7 +300,7 @@ fun SelectItem(
|
||||
vertical = SettingsItemsPaddings.Vertical,
|
||||
),
|
||||
label = { Text(text = label) },
|
||||
value = options.getOrNull(selectedIndex)?.toString().orEmpty(),
|
||||
value = options[selectedIndex].toString(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
|
||||
Reference in New Issue
Block a user