Compare commits

...
Author SHA1 Message Date
AntsyLich 9612d955db Release v0.21.2 2026-10-13 01:52:15 +06:00
AntsyLich 5ca2a39328 Fix concurrent Cloudflare challenges each opening a WebView
Closes #3858

Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
(cherry picked from commit 5a5ff22c37)
2026-10-13 01:52:15 +06:00
AntsyLich fb2b224030 Fix some downloads not being recognized
(cherry picked from commit f0f1e4de5a)
2026-10-13 01:39:01 +06:00
AntsyLich ee454f3a82 Ignore long strip taps confirmed after the reader is gone
(cherry picked from commit c371d6331d)
2026-10-13 01:39:01 +06:00
AntsyLich 4fd0081817 Fail the install when nothing can confirm it instead of crashing
(cherry picked from commit 2df48e19a1)
2026-10-13 01:39:01 +06:00
AntsyLich 68dbd7a185 Show invalid filters from extensions instead of crashing
(cherry picked from commit c9e1b6e96e)
2026-10-13 01:39:01 +06:00
AntsyLich 7475f4554c Insert split pages only into the chapter they belong to
(cherry picked from commit 3156b4a1c4)
2026-10-13 01:39:00 +06:00
hazptr 8e688ed51e Fix tracker start date being shifted by the UTC offset (#4049)
Closes #1340
Closes #4049

(cherry picked from commit ee59394542)
2026-10-13 01:39:00 +06:00
AntsyLich cca6b5e2e7 Solve Cloudflare challenges of non-GET requests on the site's root
Closes #3909

(cherry picked from commit 7ae858e602)
2026-10-13 01:39:00 +06:00
AntsyLich 4c316ccc3a Update navigationevent to stop back gestures crashing closed screens
(cherry picked from commit 43de88c718)
2026-10-13 01:39:00 +06:00
AntsyLich 7c7392a533 Guard the database update against duplicates and damaged indexes
(cherry picked from commit ea2b73ad78)
2026-10-13 01:39:00 +06:00
MajorTanya d4fe03cd18 Don't send full Doubles to Comick for progress (#4090)
Previously, user could end up with progress displays like

35.019996643066406/100

because Mihon was sending raw Doubles/simply stringified Doubles for
progress data.

We now round to 2 decimal places & trim off any trailing zeroes such
that integers are sent as "1" instead of "1.0" while retaining "25.5"
and "41.02" as such.

(cherry picked from commit 1ca9b0efb8)
2026-10-13 01:39:00 +06:00
AntsyLich e9670d8a12 Release v0.21.1 2026-10-12 14:09:01 +06:00
AntsyLich 87e808548b Merge branch 'main' into 0.21.0
# Conflicts:
#	CHANGELOG.md
2026-10-12 14:04:00 +06:00
AntsyLich 79fc3a0a8a Release v0.21.0 2026-10-12 01:37:06 +06:00
AntsyLich c8b820a08f Regenerate baseline profiles 2026-10-12 01:37:05 +06:00
AntsyLich f45d0ad31d Remove the WebGPU reader and go back to the old image decoder
Brings back SSIV and the image decoder from v0.20.4 with their settings,
except "Use legacy decoder for long strip reader", which long strip
pages drawn as strips replace.
2026-10-12 01:14:22 +06:00
38 changed files with 804 additions and 3212 deletions
+1 -1
View File
@@ -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.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.2](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.21.0"
Example: "0.21.2"
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.0](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.21.2](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
+29 -35
View File
@@ -11,42 +11,34 @@ 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))
### 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
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich))
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich))
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich))
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich))
- 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))
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich))
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich))
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich))
- 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))
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich))
- 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))
- 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
@@ -794,7 +786,9 @@ 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.0...main
[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
[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
+3 -4
View File
@@ -37,8 +37,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 36
versionName = "0.21.0"
versionCode = 38
versionName = "0.21.2"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -229,6 +229,7 @@ dependencies {
debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.navigationEvent)
implementation(libs.androidx.interpolator)
@@ -291,8 +292,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,10 +8,8 @@ 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
@@ -61,10 +59,7 @@ class AddTracks(
.minOrNull()
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone(
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
val startDate = firstReadChapterDate.toEpochMilliseconds()
track = track.copy(
startDate = Instant.fromEpochMilliseconds(startDate),
)
@@ -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() }
@@ -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)) },
)
}
}
}
}
@@ -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
@@ -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, sanitizeScanlator = true)
.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
}
/**
@@ -254,14 +254,18 @@ 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, sanitizeScanlator = true)
parts.dirName(disallowNonAsciiFilenames, enableChapterNameHash)
}
val splitChapterDirNames = booleanPairPermutation
.map { (disallowNonAsciiFilenames, enableChapterNameHash) ->
parts.splitDirName(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.dirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
parts.splitDirName(disallowNonAsciiFilenames, enableChapterNameHash, sanitizeScanlator = false)
}
} else {
emptyList()
@@ -272,6 +276,7 @@ class DownloadProvider(
// chapter names)
add(chapterNameV1)
addAll(othersChapterDirNames)
addAll(splitChapterDirNames)
addAll(unsanitizedScanlatorChapterDirNames)
}
}
@@ -291,7 +296,6 @@ class DownloadProvider(
val chapterDirName = parts.dirName(
disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames.get(),
enableHash = libraryPreferences.enableChapterNameHash.get(),
sanitizeScanlator = true,
)
val legacyChapterDirNames = getLegacyChapterDirNames(parts)
@@ -330,9 +334,29 @@ 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, sanitizeScanlator: Boolean): String {
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 {
return buildString {
if (!chapterScanlator.isNullOrBlank()) {
// A "/" in the scanlator would otherwise nest the chapter in a folder of its own
@@ -33,6 +33,7 @@ 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
@@ -77,7 +78,14 @@ class ComickApi(
put("status", track.status.toApiListStatus())
if (track.last_chapter_read > 0) {
putJsonObject("progress") {
put("number", track.last_chapter_read)
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
} else {
put("progress", null)
@@ -140,7 +148,14 @@ class ComickApi(
put("rating", track.score.toInt().takeIf { it > 0 })
put("status", track.status.toApiListStatus())
putJsonObject("progress") {
put("number", track.last_chapter_read.toString())
put(
"number",
track.last_chapter_read
.toBigDecimal()
.setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString(),
)
}
}
.toString()
@@ -3,6 +3,7 @@ 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
@@ -57,7 +58,16 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
return
}
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
service.startActivity(userAction)
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)
}
}
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 -> throw IllegalStateException("Unknown TriState state: $this")
else -> TriState.DISABLED
}
}
@@ -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
}
@@ -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: MutableMap<Int, InsertPage> = mutableMapOf()
private var preprocessed: List<InsertPage> = emptyList()
var nextTransition: ChapterTransition.Next? = null
private set
@@ -68,13 +68,15 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val lastPage = pages.last()
// Insert preprocessed pages into current page list
preprocessed.keys.sortedDescending()
.forEach { key ->
if (lastPage.index == key) {
insertPageLastPage = preprocessed[key]
// 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
}
preprocessed[key]?.let { pages.add(key + 1, it) }
pages.add(page.index + 1, page)
}
newItems.addAll(pages)
@@ -105,7 +107,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
newItems.reverse()
}
preprocessed = mutableMapOf()
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
items = newItems
notifyDataSetChanged()
@@ -161,9 +164,12 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
val currentIndex = items.indexOf(currentPage)
// Put aside preprocessed pages for next chapter so they don't get removed when changing chapter
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
preprocessed[newPage.index] = newPage
val chapterId = newPage.chapter.chapter.id
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
preprocessed = preprocessed + 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
@@ -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)
}
}
@@ -243,7 +243,9 @@ class WebtoonRecyclerView @JvmOverloads constructor(
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
if (!tapDuringManualScroll) {
// 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) {
tapListener?.invoke(ev)
}
return false
@@ -0,0 +1,73 @@
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),
)
}
}
@@ -10,13 +10,15 @@ 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.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
@@ -24,7 +26,11 @@ 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,
@@ -35,6 +41,18 @@ 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:
@@ -42,19 +60,50 @@ class CloudflareInterceptor(
return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
}
override fun intercept(
chain: Interceptor.Chain,
request: Request,
response: Response,
): Response {
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?) {
try {
response.close()
cookieManager.remove(request.url, COOKIE_NAMES, 0)
val oldCookie = cookieManager.get(request.url)
.firstOrNull { it.name == "cf_clearance" }
resolveWithWebView(request, oldCookie)
return chain.proceed(request)
resolveWithWebView(request, clearance)
}
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
@@ -66,65 +115,84 @@ class CloudflareInterceptor(
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
private fun resolveWithWebView(originalRequest: Request, originalClearance: String?) {
// 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)
webview.addJavascriptInterface(
object {
@Suppress("unused")
@JavascriptInterface
fun interactiveDetected() {
// The challenge cannot be solved non-interactively, abort.
fun onChallengeEvent(event: String) {
when (event) {
// The challenge cannot be solved non-interactively, abort.
"interactiveBegin" -> latch.countDown()
"fail" -> {
challengeFailed = true
latch.countDown()
}
},
"mihon",
)
}
}
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",
)
}
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return cookieManager.get(origRequestUrl.toHttpUrl())
.firstOrNull { it.name == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) {
cloudflareBypassed = true
latch.countDown()
}
if (url == origRequestUrl) {
if (url == challengeUrl) {
if (!challengeFound) {
// The first request didn't return the challenge, abort.
latch.countDown()
} else {
// Listen for an interactiveBegin event
view.evaluateJavascript(
"""
addEventListener("message", ({data}) => {
if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") {
mihon.interactiveDetected();
}
})
""".trimIndent(),
null,
)
} else if (!useIsolatedWorld) {
view.evaluateJavascript(listenerScript, null)
}
}
}
@@ -157,7 +225,7 @@ class CloudflareInterceptor(
}
}
webview.loadUrl(origRequestUrl, headers)
webview.loadUrl(challengeUrl, headers)
}
latch.awaitFor30Seconds()
@@ -169,13 +237,27 @@ class CloudflareInterceptor(
}
webview?.run {
if (!isRenderProcessGone) stopLoading()
if (!isRenderProcessGone) {
if (useIsolatedWorld) {
WebViewCompat.removeWebMessageListener(
this,
WebViewCompat.getExecutionWorld(this, "mihon"),
"mihon",
)
} else {
removeJavascriptInterface("mihon")
}
listenerScriptHandler?.remove()
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)
@@ -186,6 +268,11 @@ 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")
@@ -59,15 +59,23 @@ 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 response
return false
}
initWebView
return intercept(chain, request, response)
return true
}
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
@@ -4,6 +4,64 @@ 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
-- ---------------------------------------------------------------------------------------------------------
@@ -21,11 +79,6 @@ 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
@@ -326,8 +379,7 @@ 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, which leaves
-- `history_history_chapter_id_index` a prefix of that constraint.
-- both are renamed to say so. The chapter it belongs to is already unique, so the constraint indexes it.
-- 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
@@ -349,11 +401,14 @@ 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 H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read
SELECT min(H._id), C.manga_id, H.chapter_id, max(H.last_read), sum(H.time_read)
FROM history_temp H
JOIN chapter C
ON C.id = H.chapter_id;
ON C.id = H.chapter_id
GROUP BY H.chapter_id;
DROP TABLE history_temp;
@@ -388,19 +443,20 @@ 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, 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.
-- 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.
CREATE TEMP TABLE duplicate_tracks AS
SELECT
T._id,
first_value(T._id) OVER (
PARTITION BY D.keep_id, T.sync_id
PARTITION BY coalesce(D.keep_id, T.manga_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
JOIN duplicate_mangas D
LEFT JOIN duplicate_mangas D
ON D._id = T.manga_id;
DELETE FROM manga_sync
+6 -6
View File
@@ -15,6 +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.2"
androidx-preference = "1.2.1"
androidx-profileInstaller = "1.4.1"
@@ -41,7 +42,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"
@@ -70,13 +71,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]
@@ -110,6 +110,7 @@ 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" }
@@ -140,7 +141,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" }
@@ -190,14 +191,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>
@@ -300,7 +300,7 @@ fun SelectItem(
vertical = SettingsItemsPaddings.Vertical,
),
label = { Text(text = label) },
value = options[selectedIndex].toString(),
value = options.getOrNull(selectedIndex)?.toString().orEmpty(),
onValueChange = {},
readOnly = true,
singleLine = true,