Compare commits

...

35 Commits

Author SHA1 Message Date
Jobobby04 7b0b879d65 Downgrade crashlytics plugin 2024-05-05 00:07:41 -04:00
Dexroneum 8a622f6c7d [RU] Translations (#1161)
* [RU] Translations

* [RU] Deleted unused strings
2024-05-04 23:39:57 -04:00
Jobobby04 253060a3bc Minor cleanup 2024-05-04 23:15:17 -04:00
Jobobby04 b6b33e8c00 Get new page url on image fetch failure for EHentai 2024-05-04 23:13:52 -04:00
Jobobby04 2e4f811090 Add getImageUrl override to EHentai 2024-05-04 23:13:26 -04:00
Jobobby04 215a1908f7 Include lewd filter in filter highlight 2024-05-04 23:13:06 -04:00
Jobobby04 082acf000c Fix Local Manga details edit 2024-05-04 23:12:45 -04:00
Jobobby04 2d1240b274 Update dependencies and cleanup 2024-05-04 18:41:03 -04:00
Jobobby04 1e98709cc3 Revert "Fix badge count getting cut off on tab title"
This reverts commit f9148c0c5e.
2024-05-04 18:12:54 -04:00
AntsyLich 5550ddad4e Bump compose version
Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
(cherry picked from commit e473c7f09fc009161145aca94bd70027f042b0bf)

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt
#	app/src/main/java/eu/kanade/presentation/manga/components/MangaInfoHeader.kt
2024-05-04 17:07:27 -04:00
AntsyLich f9148c0c5e Fix badge count getting cut off on tab title
Fixes #335

(cherry picked from commit 263e467cdeb948b8f3679e2ea0282a291cf2f131)
2024-05-04 16:57:20 -04:00
Radon Rosborough 089e6268e7 Massively improve findFile performance (#728)
* Massively improve findFile performance

* Update libs.versions.toml

---------

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
(cherry picked from commit 7ec2108812fbe0483111dbe996e29e5a621b583a)
2024-05-04 16:56:45 -04:00
AntsyLich 712cd1493f Address firebase ktx module deprecation
(cherry picked from commit 28dca3b7b818ad095008e7cd49ec07a82b0ebcad)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 16:52:36 -04:00
AntsyLich bbc8adc3e8 Trust extension by repo (#570)
(cherry picked from commit 70cd688ac245a70a3146e2ac7374f24b0c3453ab)
2024-05-04 16:51:47 -04:00
AntsyLich 077b673c0a Fix some extension related issue and cleanups
- Extension being marked as not installed instead of untrusted after updating with private installer
- Extension update counter not updating due to extension being marked as untrusted
- Minimize `Key "extension-XXX-YYY" was already used` crash

(cherry picked from commit 21145144cdf550aa775047603e06e261951ebc42)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
2024-05-04 16:51:26 -04:00
renovate[bot] 49eacf5178 fix(deps): update leakcanary to v2.14 (#715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit fa6dba6cc76f0b08cbc9bf222b0e087f4fb16d76)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 16:01:19 -04:00
renovate[bot] 98d1dddf4a fix(deps): update dependency com.android.tools.build:gradle to v8.4.0 (#753)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 8a51d56c594e1f7ae4ebc01fc6a639292dde78bd)
2024-05-04 16:00:27 -04:00
renovate[bot] 37a616f3db fix(deps): update dependency androidx.test.espresso:espresso-core to v3.6.0-alpha04 (#749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit a2f7d47a0a65bf88ac609b2227d440a7a2f841bf)
2024-05-04 16:00:14 -04:00
renovate[bot] ad18696a1a fix(deps): update dependency androidx.core:core-ktx to v1.13.1 (#748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit b720f34267e4111466cdabf3a298d006231e4b55)
2024-05-04 15:59:53 -04:00
renovate[bot] 34bb012a1c fix(deps): update dependency androidx.test.ext:junit-ktx to v1.2.0-alpha04 (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit c6a1412f18cb16f89c4ddeadb3448c141a49072e)
2024-05-04 15:59:43 -04:00
renovate[bot] 08c4989aa3 fix(deps): update aboutlib.version to v11.1.4 (#744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 6290cf222df922240575e2199459ab7b707d6ae2)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 15:59:38 -04:00
FooIbar 14dae420f5 Log app crash exceptions in dumped crash logs (#742)
(cherry picked from commit a3d438e2f5b427eb8b4c391ab9fe10c5a83baf29)
2024-05-04 15:59:04 -04:00
w 65ed3c5ae6 Update subsampling-scale-image-view (#687)
Update libs.versions.toml

(cherry picked from commit 80461d883f7d6ca2203ae7455223ff49e8ef96ab)
2024-05-04 15:58:49 -04:00
FooIbar 5ae3508665 Use Coil pipeline instead of SSIV for image decode (#692)
(cherry picked from commit c3e7bb12f4cccf42dd3ea169111c771876e640fe)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt
2024-05-04 15:58:41 -04:00
MajorTanya e32eb0e009 Add MyAnimeList issue autoclose (#703)
[skip ci] Add MyAnimeList issue autoclose

This rule is intended to automatically close issues that report
problems with linking MAL that would be solved with the standard
solution of updating & changing the default UA.

The RegEx might be too general, but there isn't any neat pattern in
the previously filed issues.

(cherry picked from commit 9a3ffe2ea6cbf7ef2c2966c304a54b715a5fa682)
2024-05-04 15:51:26 -04:00
renovate[bot] e0812ab5c8 fix(deps): update dependency androidx.compose.compiler:compiler to v1.5.12 (#685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 213effa169e28e144f3e323290d865b02d0bf94b)
2024-05-04 15:51:13 -04:00
renovate[bot] df9f79c120 fix(deps): update dependency androidx.benchmark:benchmark-macro-junit4 to v1.2.4 (#684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 25570147a1ca8ac374a75d7f29cf105bd686954b)
2024-05-04 15:51:04 -04:00
renovate[bot] 990eb33b98 fix(deps): update dependency androidx.activity:activity-compose to v1.9.0 (#689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 2ad462b4d882c4a03359092515aa6b8d3cb4fd5d)
2024-05-04 15:50:50 -04:00
renovate[bot] e1bab1172a fix(deps): update dependency androidx.core:core-ktx to v1.13.0 (#690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 7fd8f653529b8e1488dd57c051000abf2a80ed12)
2024-05-04 15:50:41 -04:00
FooIbar aeeff72bed Use Okio instead of java.io for image processing (#691)
(cherry picked from commit b152e3881bffd9050a8a0ed4030823886e3fe04f)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/App.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt
#	core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt
2024-05-04 15:50:20 -04:00
FooIbar 5895e78b39 Use m3 ripple and clean up interactionSource usage (#675)
Also remove a leftover of scoped storage adaptation.

(cherry picked from commit f27ca3b1b2f92258c213bca6b27d8eff4c7363ad)

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt
2024-05-04 15:05:16 -04:00
FooIbar b24719a3e9 Update compose bom and fix renovate config for it (#674)
(cherry picked from commit 843daa5304d0b1a93ba69f8cc69791e446a58596)

# Conflicts:
#	.github/renovate.json5
2024-05-04 15:04:40 -04:00
renovate[bot] d551619d9d fix(deps): update dependency com.google.firebase:firebase-analytics-ktx to v21.6.2 (#656)
Update dependency com.google.firebase:firebase-analytics-ktx to v21.6.2

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit f080a4937e61d3dde5473876c34db8f16844e30c)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 15:04:12 -04:00
renovate[bot] 06ad6c2e16 Update aboutlib.version to v11.1.3 (#654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 4c43a0ef66e9c8a321fb745b860319aaa074c57f)

# Conflicts:
#	gradle/libs.versions.toml
2024-05-04 15:03:39 -04:00
renovate[bot] df7e470e08 Update dependency com.android.tools.build:gradle to v8.3.2 (#655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ea0fe2414e1e30b6e82ddf65144035283b31a5c4)
2024-05-04 15:02:59 -04:00
74 changed files with 682 additions and 848 deletions
+6
View File
@@ -40,6 +40,12 @@ jobs:
"ignoreCase": true,
"labels": ["Cloudflare protected"],
"message": "Refer to the **Solving Cloudflare issues** section at https://mihon.app/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection."
},
{
"type": "both",
"regex": "^.*(myanimelist|mal).*$",
"ignoreCase": true,
"message": "For issues with linking MyAnimeList, please follow these steps:\n1. Update Mihon to version 0.16.4 or newer\n2. Change your default User-Agent (`More → Settings → Advanced → Default user agent string`)\n3. Close and restart Mihon\n4. Attempt to link MyAnimeList again\n\nIf you had MyAnimeList linked before, try to unlink it first before trying these steps."
}
]
auto-close-ignore-label: do-not-autoclose
-2
View File
@@ -254,7 +254,6 @@ dependencies {
implementation(libs.logcat)
// Crash reports/analytics
// implementation(libs.bundles.acra)
// "standardImplementation"(libs.firebase.analytics)
// Shizuku
@@ -315,7 +314,6 @@ tasks {
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
@@ -179,7 +179,7 @@ class DomainModule : InjektModule {
addFactory { ToggleLanguage(get()) }
addFactory { ToggleSource(get()) }
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get()) }
addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { ExtensionRepoService(get(), get()) }
@@ -2,8 +2,6 @@ package eu.kanade.domain.base
import android.content.Context
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.i18n.MR
@@ -22,8 +20,6 @@ class BasePreferences(
fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore)
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", isPreviewBuildType || isReleaseBuildType)
fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false)
enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) {
@@ -20,7 +20,7 @@ class GetExtensionsByType(
extensionManager.installedExtensionsFlow,
extensionManager.untrustedExtensionsFlow,
extensionManager.availableExtensionsFlow,
) { _activeLanguages, _installed, _untrusted, _available ->
) { enabledLanguages, _installed, _untrusted, _available ->
val (updates, installed) = _installed
.filter { (showNsfwSources || !it.isNsfw) }
.sortedWith(
@@ -41,9 +41,9 @@ class GetExtensionsByType(
}
.flatMap { ext ->
if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in _activeLanguages) listOf(ext) else emptyList()
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
}
ext.sources.filter { it.lang in _activeLanguages }
ext.sources.filter { it.lang in enabledLanguages }
.map {
ext.copy(
name = it.name,
@@ -3,15 +3,18 @@ package eu.kanade.domain.extension.interactor
import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.core.common.preference.getAndSet
class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository,
private val preferences: SourcePreferences,
) {
fun isTrusted(pkgInfo: PackageInfo, signatureHash: String): Boolean {
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:$signatureHash"
return key in preferences.trustedExtensions().get()
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet()
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
}
fun trust(pkgName: String, versionCode: Long, signatureHash: String) {
@@ -19,9 +22,7 @@ class TrustExtension(
// Remove previously trusted versions
val removed = exts.filterNot { it.startsWith("$pkgName:") }.toMutableSet()
removed.also {
it += "$pkgName:$versionCode:$signatureHash"
}
removed.also { it += "$pkgName:$versionCode:$signatureHash" }
}
}
@@ -5,7 +5,6 @@ import android.net.Uri
import android.provider.Settings
import android.util.DisplayMetrics
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -199,7 +198,7 @@ private fun ExtensionDetails(
key = { it.source.id },
) { source ->
SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
source = source,
onClickSourcePreferences = onClickSourcePreferences,
onClickSource = onClickSource,
@@ -362,10 +361,8 @@ private fun InfoText(
primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge,
onClick: (() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val clickableModifier = if (onClick != null) {
Modifier.clickable(interactionSource, indication = null) { onClick() }
Modifier.clickable(interactionSource = null, indication = null, onClick = onClick)
} else {
Modifier
}
@@ -58,7 +58,7 @@ private fun ExtensionFilterContent(
) {
items(state.languages) { language ->
SwitchPreferenceWidget(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
title = LocaleHelper.getSourceDisplayName(language, context),
checked = language in state.enabledLanguages,
onCheckedChanged = { onClickLang(language) },
@@ -188,14 +188,14 @@ private fun ExtensionContent(
}
ExtensionHeader(
textRes = header.textRes,
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
action = action,
)
}
is ExtensionUiModel.Header.Text -> {
ExtensionHeader(
text = header.text,
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
)
}
}
@@ -213,7 +213,7 @@ private fun ExtensionContent(
},
) { item ->
ExtensionItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
item = item,
onClickItem = {
when (it) {
@@ -103,7 +103,7 @@ fun FeedScreen(
key = { it.feed.id },
) { item ->
GlobalSearchResultItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
title = item.title,
subtitle = item.subtitle,
onLongClick = {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import eu.kanade.presentation.browse.components.GlobalSearchCardRow
import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
@@ -80,6 +81,7 @@ internal fun GlobalSearchContent(
} ?: source.name,
subtitle = LocaleHelper.getLocalizedDisplayName(source.lang),
onClick = { onClickSource(source) },
modifier = Modifier.animateItem(),
) {
when (result) {
SearchItemResult.Loading -> {
@@ -144,7 +144,7 @@ private fun MigrateSourceList(
key = { (source, _) -> "migrate-${source.id}" },
) { (source, count) ->
MigrateSourceItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
source = source,
count = count,
onClickItem = { onClickItem(source) },
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.ArrowForward
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.CopyAll
import androidx.compose.material.icons.outlined.Done
@@ -95,7 +94,7 @@ fun MigrationListScreen(
Row(
Modifier
.fillMaxWidth()
.animateItemPlacement()
.animateItem()
.padding(horizontal = 16.dp)
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -153,7 +153,7 @@ fun SourceFeedList(
key = { it.id },
) { item ->
GlobalSearchResultItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
title = item.title,
subtitle = null,
onLongClick = if (item is SourceFeedUI.SourceSavedSearch) {
@@ -79,7 +79,7 @@ private fun SourcesFilterContent(
contentType = "source-filter-header",
) {
SourcesFilterHeader(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
language = language,
enabled = enabled,
onClickItem = onClickLanguage,
@@ -95,7 +95,7 @@ private fun SourcesFilterContent(
sources.none { it.id.toString() in state.disabledSources }
}
SourcesFilterToggle(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
isEnabled = toggleEnabled,
onClickItem = {
onClickSources(!toggleEnabled, sources)
@@ -109,7 +109,7 @@ private fun SourcesFilterContent(
contentType = { "source-filter-item" },
) { source ->
SourcesFilterItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
source = source,
enabled = "${source.id}" !in state.disabledSources,
onClickItem = onClickSource,
@@ -81,7 +81,7 @@ fun SourcesScreen(
when (model) {
is SourceUiModel.Header -> {
SourceHeader(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
language = model.language,
// SY -->
isCategory = model.isCategory,
@@ -89,7 +89,7 @@ fun SourcesScreen(
)
}
is SourceUiModel.Item -> SourceItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
source = model.source,
// SY -->
showLatest = state.showLatest,
@@ -30,9 +30,6 @@ import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun GlobalSearchResultItem(
// SY -->
modifier: Modifier = Modifier,
// SY <--
title: String,
// SY -->
subtitle: String?,
@@ -41,9 +38,10 @@ fun GlobalSearchResultItem(
// SY -->
onLongClick: (() -> Unit)? = null,
// SY <--
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(modifier) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.padding(
@@ -107,7 +107,7 @@ private fun CategoryContent(
key = { _, category -> "category-${category.id}" },
) { index, category ->
CategoryListItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
category = category,
canMoveUp = index != 0,
canMoveDown = index != categories.lastIndex,
@@ -10,8 +10,7 @@ import androidx.compose.ui.Modifier
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.presentation.core.util.shouldExpandFAB
@Composable
fun CategoryFloatingActionButton(
@@ -23,7 +22,7 @@ fun CategoryFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_add)) },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = onCreate,
expanded = lazyListState.isScrollingUp() || lazyListState.isScrolledToEnd(),
expanded = lazyListState.shouldExpandFAB(),
modifier = modifier,
)
}
@@ -26,7 +26,7 @@ fun BiometricTimesContent(
) {
items(timeRanges, key = { it.formattedString }) { timeRange ->
BiometricTimesListItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
timeRange = timeRange,
onDelete = { onClickDelete(timeRange) },
)
@@ -27,7 +27,7 @@ fun SortTagContent(
) {
itemsIndexed(tags, key = { _, tag -> tag }) { index, tag ->
SortTagListItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
tag = tag,
canMoveUp = index != 0,
canMoveDown = index != tags.lastIndex,
@@ -26,7 +26,7 @@ fun SourceCategoryContent(
) {
items(categories, key = { it }) { category ->
SourceCategoryListItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
category = category,
onRename = { onClickRename(category) },
onDelete = { onClickDelete(category) },
@@ -37,7 +37,7 @@ fun CrashScreen(
acceptText = stringResource(MR.strings.pref_dump_crash_logs),
onAcceptClick = {
scope.launch {
CrashLogUtil(context).dumpLogs()
CrashLogUtil(context).dumpLogs(exception)
}
},
rejectText = stringResource(MR.strings.crash_screen_restart_application),
@@ -114,14 +114,14 @@ private fun HistoryScreenContent(
when (item) {
is HistoryUiModel.Header -> {
ListGroupHeader(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
text = relativeDateText(item.date),
)
}
is HistoryUiModel.Item -> {
val value = item.item
HistoryItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
history = value,
onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) },
@@ -102,8 +102,7 @@ import tachiyomi.presentation.core.components.material.ExtendedFloatingActionBut
import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.presentation.core.util.shouldExpandFAB
import tachiyomi.source.local.isLocal
import java.time.Instant
import java.time.ZoneId
@@ -431,7 +430,7 @@ private fun MangaScreenSmallImpl(
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
expanded = chapterListState.shouldExpandFAB(),
)
}
},
@@ -755,7 +754,7 @@ fun MangaScreenLargeImpl(
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
expanded = chapterListState.isScrollingUp() || chapterListState.isScrolledToEnd(),
expanded = chapterListState.shouldExpandFAB(),
)
}
},
@@ -9,13 +9,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.ripple
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -8,7 +8,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -33,12 +32,12 @@ import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.RemoveDone
import androidx.compose.material.icons.outlined.SwapCalls
import androidx.compose.material.ripple
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -199,7 +198,7 @@ private fun RowScope.Button(
.size(48.dp)
.weight(animatedWeight)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
interactionSource = null,
indication = ripple(bounded = false),
onLongClick = onLongClick,
onClick = onClick,
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
@@ -141,7 +141,7 @@ fun TagsChip(
border: ChipBorder? = SuggestionChipDefaults.suggestionChipBorder(),
borderM3: BorderStroke? = SuggestionChipDefaultsM3.suggestionChipBorder(enabled = true),
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
if (onClick != null) {
SuggestionChip(
modifier = modifier,
@@ -32,8 +32,6 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.TextButton
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable
fun ScanlatorFilterDialog(
@@ -97,8 +95,8 @@ fun ScanlatorFilterDialog(
}
}
}
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
},
properties = DialogProperties(
@@ -28,11 +28,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR
@@ -46,7 +46,7 @@ fun ExtensionReposContent(
repos.forEach {
item {
ExtensionRepoListItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
repo = it,
onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) },
@@ -26,8 +26,6 @@ import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable
fun <T> ListPreferenceWidget(
@@ -69,8 +67,8 @@ fun <T> ListPreferenceWidget(
}
}
}
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
},
confirmButton = {
@@ -30,8 +30,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
private enum class State {
CHECKED, INVERSED, UNCHECKED
@@ -115,16 +113,8 @@ fun <T> TriStateListDialog(
}
}
if (!listState.isScrolledToStart()) {
HorizontalDivider(
modifier = Modifier.align(Alignment.TopCenter),
)
}
if (!listState.isScrolledToEnd()) {
HorizontalDivider(
modifier = Modifier.align(Alignment.BottomCenter),
)
}
if (listState.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (listState.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
}
},
@@ -43,8 +43,6 @@ import tachiyomi.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.components.material.AlertDialogContent
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrolledToStart
@Composable
fun TrackStatusSelector(
@@ -86,8 +84,8 @@ fun TrackStatusSelector(
}
}
}
if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
},
onConfirm = onConfirm,
onDismissRequest = onDismissRequest,
@@ -54,7 +54,7 @@ internal fun LazyListScope.updatesLastUpdatedItem(
item(key = "updates-lastUpdated") {
Box(
modifier = Modifier
.animateItemPlacement()
.animateItem()
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
) {
Text(
@@ -94,14 +94,14 @@ internal fun LazyListScope.updatesUiItems(
when (item) {
is UpdatesUiModel.Header -> {
ListGroupHeader(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
text = relativeDateText(item.date),
)
}
is UpdatesUiModel.Item -> {
val updatesItem = item.item
UpdatesUiItem(
modifier = Modifier.animateItemPlacement(),
modifier = Modifier.animateItem(),
update = updatesItem.update,
selected = updatesItem.selected,
readProgress = updatesItem.update.lastPageRead
@@ -9,9 +9,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
@Composable
fun rememberRequestPackageInstallsPermissionState(initialValue: Boolean = false): Boolean {
@@ -38,6 +38,7 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
import eu.kanade.tachiyomi.crash.CrashActivity
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
import eu.kanade.tachiyomi.data.coil.MangaKeyer
@@ -208,6 +209,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
add(MangaKeyer())
add(MangaCoverKeyer())
add(BufferedSourceFetcher.Factory())
// SY -->
add(PagePreviewKeyer())
add(PagePreviewFetcher.Factory(callFactoryLazy))
@@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.data.coil
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import okio.BufferedSource
class BufferedSourceFetcher(
private val data: BufferedSource,
private val options: Options,
) : Fetcher {
override suspend fun fetch(): FetchResult {
return SourceFetchResult(
source = ImageSource(
source = data,
fileSystem = options.fileSystem,
),
mimeType = null,
dataSource = DataSource.MEMORY,
)
}
class Factory : Fetcher.Factory<BufferedSource> {
override fun create(
data: BufferedSource,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BufferedSourceFetcher(data, options)
}
}
}
@@ -1,15 +1,18 @@
package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader
import coil3.asCoilImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.bitmapConfig
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.GLUtil
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import okio.BufferedSource
@@ -38,29 +41,58 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
}
val decoder = resources.sourceOrNull()?.use {
zip4j.use { zipFile ->
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream())
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream(), options.cropBorders, displayProfile)
}
}
// SY <--
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
val bitmap = decoder.decode()
val srcWidth = decoder.width
val srcHeight = decoder.height
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
var bitmap = decoder.decode(sampleSize = sampleSize)
decoder.recycle()
check(bitmap != null) { "Failed to decode image" }
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
options.bitmapConfig == Bitmap.Config.HARDWARE &&
maxOf(bitmap.width, bitmap.height) <= GLUtil.maxTextureSize
) {
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
if (hwBitmap != null) {
bitmap.recycle()
bitmap = hwBitmap
}
}
return DecodeResult(
image = bitmap.asCoilImage(),
isSampled = false,
isSampled = sampleSize > 1,
)
}
class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
if (!isApplicable(result.source.source())) return null
return TachiyomiImageDecoder(result.source, options)
return if (options.customDecoder || isApplicable(result.source.source())) {
TachiyomiImageDecoder(result.source, options)
} else {
null
}
}
private fun isApplicable(source: BufferedSource): Boolean {
@@ -83,4 +115,8 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
override fun hashCode() = javaClass.hashCode()
}
companion object {
var displayProfile: ByteArray? = null
}
}
@@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.data.coil
import coil3.Extras
import coil3.getExtra
import coil3.request.ImageRequest
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Scale
import coil3.size.Size
import coil3.size.isOriginal
import coil3.size.pxOrElse
internal inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
internal inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
internal fun Dimension.toPx(scale: Scale): Int = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
fun ImageRequest.Builder.cropBorders(enable: Boolean) = apply {
extras[cropBordersKey] = enable
}
val Options.cropBorders: Boolean
get() = getExtra(cropBordersKey)
private val cropBordersKey = Extras.Key(default = false)
fun ImageRequest.Builder.customDecoder(enable: Boolean) = apply {
extras[customDecoderKey] = enable
}
val Options.customDecoder: Boolean
get() = getExtra(customDecoderKey)
private val customDecoderKey = Extras.Key(default = false)
@@ -475,7 +475,7 @@ class DownloadManager(
fun renameMangaDir(oldTitle: String, newTitle: String, source: Long) {
val sourceDir = provider.findSourceDir(sourceManager.getOrStub(source)) ?: return
val mangaDir = sourceDir.findFile(DiskUtil.buildValidFilename(oldTitle), true) ?: return
val mangaDir = sourceDir.findFile(DiskUtil.buildValidFilename(oldTitle)) ?: return
mangaDir.renameTo(DiskUtil.buildValidFilename(newTitle))
}
}
@@ -57,7 +57,7 @@ class DownloadProvider(
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return downloadsDir?.findFile(getSourceDirName(source), true)
return downloadsDir?.findFile(getSourceDirName(source))
}
/**
@@ -68,7 +68,7 @@ class DownloadProvider(
*/
fun findMangaDir(mangaTitle: String, source: Source): UniFile? {
val sourceDir = findSourceDir(source)
return sourceDir?.findFile(getMangaDirName(mangaTitle), true)
return sourceDir?.findFile(getMangaDirName(mangaTitle))
}
/**
@@ -82,7 +82,7 @@ class DownloadProvider(
fun findChapterDir(chapterName: String, chapterScanlator: String?, mangaTitle: String, source: Source): UniFile? {
val mangaDir = findMangaDir(mangaTitle, source)
return getValidChapterDirNames(chapterName, chapterScanlator).asSequence()
.mapNotNull { mangaDir?.findFile(it, true) }
.mapNotNull { mangaDir?.findFile(it) }
.firstOrNull()
}
@@ -97,7 +97,7 @@ class DownloadProvider(
val mangaDir = findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence()
.mapNotNull { mangaDir.findFile(it, true) }
.mapNotNull { mangaDir.findFile(it) }
.firstOrNull()
}
}
@@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo
import exh.source.isEhBasedSource
import exh.util.DataSaver
import exh.util.DataSaver.Companion.getImage
import kotlinx.coroutines.CancellationException
@@ -511,6 +512,9 @@ class Downloader(
.retryWhen { _, attempt ->
if (attempt < 3) {
delay((2L shl attempt.toInt()) * 1000)
if (source.isEhBasedSource()) {
page.imageUrl = source.getImageUrl(page)
}
true
} else {
false
@@ -709,7 +713,7 @@ class Downloader(
)
// Remove the old file
dir.findFile(COMIC_INFO_FILE, true)?.delete()
dir.findFile(COMIC_INFO_FILE)?.delete()
dir.createFile(COMIC_INFO_FILE)!!.openOutputStream().use {
val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo)
it.write(comicInfoString.toByteArray())
@@ -20,9 +20,8 @@ import exh.source.BlacklistedSources
import exh.source.EH_SOURCE_ID
import exh.source.EXH_SOURCE_ID
import exh.source.MERGED_SOURCE_ID
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -32,7 +31,6 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import logcat.LogPriority
import tachiyomi.core.common.util.lang.launchNow
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.model.StubSource
@@ -54,6 +52,8 @@ class ExtensionManager(
private val trustExtension: TrustExtension = Injekt.get(),
) {
val scope = CoroutineScope(SupervisorJob())
// SY -->
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
@@ -71,13 +71,28 @@ class ExtensionManager(
private val iconMap = mutableMapOf<String, Drawable>()
private val _installedExtensionsFlow = MutableStateFlow(emptyList<Extension.Installed>())
val installedExtensionsFlow = _installedExtensionsFlow.asStateFlow()
private val _installedExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>())
val installedExtensionsFlow = _installedExtensionsMapFlow.mapExtensions(scope)
private val _availableExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
val availableExtensionsFlow = _availableExtensionsMapFlow.mapExtensions(scope)
private val _untrustedExtensionsMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>())
val untrustedExtensionsFlow = _untrustedExtensionsMapFlow.mapExtensions(scope)
init {
initExtensions()
ExtensionInstallReceiver(InstallationListener()).register(context)
}
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet()
fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
val pkgName = _installedExtensionsMapFlow.value.values
.find { ext ->
ext.sources.any { it.id == sourceId }
}
?.pkgName
if (pkgName != null) {
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
@@ -95,15 +110,6 @@ class ExtensionManager(
// SY <--
}
private val _availableExtensionsFlow = MutableStateFlow(emptyList<Extension.Available>())
// SY -->
@OptIn(DelicateCoroutinesApi::class)
val availableExtensionsFlow = _availableExtensionsFlow
.map { it.filterNotBlacklisted() }
.stateIn(GlobalScope, SharingStarted.Eagerly, emptyList())
// SY <--
private var availableExtensionsSourcesData: Map<Long, StubSource> = emptyMap()
private fun setupAvailableExtensionsSourcesDataMap(extensions: List<Extension.Available>) {
@@ -115,27 +121,19 @@ class ExtensionManager(
fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
private val _untrustedExtensionsFlow = MutableStateFlow(emptyList<Extension.Untrusted>())
val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow()
init {
initExtensions()
ExtensionInstallReceiver(InstallationListener()).register(context)
}
/**
* Loads and registers the installed extensions.
*/
private fun initExtensions() {
val extensions = ExtensionLoader.loadExtensions(context)
_installedExtensionsFlow.value = extensions
_installedExtensionsMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>()
.map { it.extension }
.associate { it.extension.pkgName to it.extension }
_untrustedExtensionsFlow.value = extensions
_untrustedExtensionsMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>()
.map { it.extension }
.associate { it.extension.pkgName to it.extension }
// SY -->
.filterNotBlacklisted()
@@ -144,9 +142,9 @@ class ExtensionManager(
}
// EXH -->
private fun <T : Extension> Iterable<T>.filterNotBlacklisted(): List<T> {
private fun <T : Extension> Map<String, T>.filterNotBlacklisted(): Map<String, T> {
val blacklistEnabled = preferences.enableSourceBlacklist().get()
return filterNot { extension ->
return filterNot { (_, extension) ->
extension.isBlacklisted(blacklistEnabled)
.also {
if (it) this@ExtensionManager.xLogD("Removing blacklisted extension: (name: %s, pkgName: %s)!", extension.name, extension.pkgName)
@@ -160,7 +158,7 @@ class ExtensionManager(
// EXH <--
/**
* Finds the available extensions in the [api] and updates [availableExtensions].
* Finds the available extensions in the [api] and updates [_availableExtensionsMapFlow].
*/
suspend fun findAvailableExtensions() {
val extensions: List<Extension.Available> = try {
@@ -173,7 +171,7 @@ class ExtensionManager(
enableAdditionalSubLanguages(extensions)
_availableExtensionsFlow.value = extensions
_availableExtensionsMapFlow.value = extensions.associateBy { it.pkgName }
updatedInstalledExtensionsStatuses(extensions)
setupAvailableExtensionsSourcesDataMap(extensions)
}
@@ -219,42 +217,38 @@ class ExtensionManager(
return
}
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList()
val installedExtensionsMap = _installedExtensionsMapFlow.value.toMutableMap()
var changed = false
for ((index, installedExt) in mutInstalledExtensions.withIndex()) {
val pkgName = installedExt.pkgName
for ((pkgName, extension) in installedExtensionsMap) {
// SY -->
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == pkgName }
val availableExt = availableExtensionsFlow.value.find { it.pkgName == pkgName }
// SY <--
if (availableExt == null && !installedExt.isObsolete) {
mutInstalledExtensions[index] = installedExt.copy(isObsolete = true)
if (availableExt == null && !extension.isObsolete) {
installedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
changed = true
// SY -->
} else if (installedExt.isBlacklisted() && !installedExt.isRedundant) {
mutInstalledExtensions[index] = installedExt.copy(isRedundant = true)
} else if (extension.isBlacklisted() && !extension.isRedundant) {
installedExtensionsMap[pkgName] = extension.copy(isRedundant = true)
changed = true
// SY <--
} else if (availableExt != null) {
val hasUpdate = installedExt.updateExists(availableExt)
if (installedExt.hasUpdate != hasUpdate) {
mutInstalledExtensions[index] = installedExt.copy(
val hasUpdate = extension.updateExists(availableExt)
if (extension.hasUpdate != hasUpdate) {
installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl,
)
changed = true
} else {
mutInstalledExtensions[index] = installedExt.copy(
installedExtensionsMap[pkgName] = extension.copy(
repoUrl = availableExt.repoUrl,
)
changed = true
}
changed = true
}
}
if (changed) {
_installedExtensionsFlow.value = mutInstalledExtensions
_installedExtensionsMapFlow.value = installedExtensionsMap
}
updatePendingUpdatesCount()
}
@@ -278,8 +272,7 @@ class ExtensionManager(
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Installed): Flow<InstallStep> {
val availableExt = _availableExtensionsFlow.value.find { it.pkgName == extension.pkgName }
?: return emptyFlow()
val availableExt = _availableExtensionsMapFlow.value[extension.pkgName] ?: return emptyFlow()
return installExtension(availableExt)
}
@@ -315,24 +308,16 @@ class ExtensionManager(
*
* @param extension the extension to trust
*/
fun trust(extension: Extension.Untrusted) {
val untrustedPkgNames = _untrustedExtensionsFlow.value.map { it.pkgName }.toSet()
if (extension.pkgName !in untrustedPkgNames) return
suspend fun trust(extension: Extension.Untrusted) {
_untrustedExtensionsMapFlow.value[extension.pkgName] ?: return
trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash)
val nowTrustedExtensions = _untrustedExtensionsFlow.value
.filter { it.pkgName == extension.pkgName && it.versionCode == extension.versionCode }
_untrustedExtensionsFlow.value -= nowTrustedExtensions
_untrustedExtensionsMapFlow.value -= extension.pkgName
launchNow {
nowTrustedExtensions
.map { extension ->
async { ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName) }.await()
}
.filterIsInstance<LoadResult.Success>()
.forEach { registerNewExtension(it.extension) }
}
ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)
.let { it as? LoadResult.Success }
?.let { registerNewExtension(it.extension) }
}
/**
@@ -348,7 +333,7 @@ class ExtensionManager(
}
// SY <--
_installedExtensionsFlow.value += extension
_installedExtensionsMapFlow.value += extension
}
/**
@@ -365,13 +350,7 @@ class ExtensionManager(
}
// SY <--
val mutInstalledExtensions = _installedExtensionsFlow.value.toMutableList()
val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName }
if (oldExtension != null) {
mutInstalledExtensions -= oldExtension
}
mutInstalledExtensions += extension
_installedExtensionsFlow.value = mutInstalledExtensions
_installedExtensionsMapFlow.value += extension
}
/**
@@ -381,14 +360,8 @@ class ExtensionManager(
* @param pkgName The package name of the uninstalled application.
*/
private fun unregisterExtension(pkgName: String) {
val installedExtension = _installedExtensionsFlow.value.find { it.pkgName == pkgName }
if (installedExtension != null) {
_installedExtensionsFlow.value -= installedExtension
}
val untrustedExtension = _untrustedExtensionsFlow.value.find { it.pkgName == pkgName }
if (untrustedExtension != null) {
_untrustedExtensionsFlow.value -= untrustedExtension
}
_installedExtensionsMapFlow.value -= pkgName
_untrustedExtensionsMapFlow.value -= pkgName
}
/**
@@ -407,14 +380,9 @@ class ExtensionManager(
}
override fun onExtensionUntrusted(extension: Extension.Untrusted) {
val installedExtension = _installedExtensionsFlow.value
.find { it.pkgName == extension.pkgName }
if (installedExtension != null) {
_installedExtensionsFlow.value -= installedExtension
} else {
_untrustedExtensionsFlow.value += extension
}
_installedExtensionsMapFlow.value -= extension.pkgName
_untrustedExtensionsMapFlow.value += extension
updatePendingUpdatesCount()
}
override fun onPackageUninstalled(pkgName: String) {
@@ -436,17 +404,24 @@ class ExtensionManager(
}
private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean {
val availableExt = availableExtension ?: _availableExtensionsFlow.value.find { it.pkgName == pkgName }
val availableExt = availableExtension
?: _availableExtensionsMapFlow.value[pkgName]
?: return false
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
}
private fun updatePendingUpdatesCount() {
val pendingUpdateCount = _installedExtensionsFlow.value.count { it.hasUpdate }
val pendingUpdateCount = _installedExtensionsMapFlow.value.values.count { it.hasUpdate }
preferences.extensionUpdatesCount().set(pendingUpdateCount)
if (pendingUpdateCount == 0) {
ExtensionUpdateNotifier(context).dismiss()
}
}
private operator fun <T : Extension> Map<String, T>.plus(extension: T) = plus(extension.pkgName to extension)
private fun <T : Extension> StateFlow<Map<String, T>>.mapExtensions(scope: CoroutineScope): StateFlow<List<T>> {
return map { it.values.toList() }.stateIn(scope, SharingStarted.Lazily, value.values.toList())
}
}
@@ -9,12 +9,10 @@ import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.lang.launchNow
import tachiyomi.core.common.util.system.logcat
/**
@@ -23,29 +21,23 @@ import tachiyomi.core.common.util.system.logcat
*
* @param listener The listener that should be notified of extension installation events.
*/
internal class ExtensionInstallReceiver(private val listener: Listener) :
BroadcastReceiver() {
internal class ExtensionInstallReceiver(private val listener: Listener) : BroadcastReceiver() {
val scope = CoroutineScope(SupervisorJob())
/**
* Registers this broadcast receiver
*/
fun register(context: Context) {
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
/**
* Returns the intent filter this receiver should subscribe to.
*/
private val filter
get() = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(ACTION_EXTENSION_ADDED)
addAction(ACTION_EXTENSION_REPLACED)
addAction(ACTION_EXTENSION_REMOVED)
addDataScheme("package")
}
private val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(ACTION_EXTENSION_ADDED)
addAction(ACTION_EXTENSION_REPLACED)
addAction(ACTION_EXTENSION_REMOVED)
addDataScheme("package")
}
/**
* Called when one of the events of the [filter] is received. When the package is an extension,
@@ -58,7 +50,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
if (isReplacing(intent)) return
launchNow {
scope.launch {
when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
@@ -67,7 +59,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
}
}
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
launchNow {
scope.launch {
when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
@@ -107,9 +99,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
logcat(LogPriority.WARN) { "Package name not found" }
return LoadResult.Error
}
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) {
ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
}.await()
return ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
}
/**
@@ -172,7 +172,7 @@ internal object ExtensionLoader {
* Attempts to load an extension from the given package name. It checks if the extension
* contains the required feature flag before trying to load it.
*/
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
if (extensionPackage == null) {
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
@@ -223,7 +223,8 @@ internal object ExtensionLoader {
* @param context The application context.
* @param extensionInfo The extension to load.
*/
private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo
val appInfo = pkgInfo.applicationInfo
@@ -252,7 +253,7 @@ internal object ExtensionLoader {
if (signatures.isNullOrEmpty()) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return LoadResult.Error
} else if (!trustExtension.isTrusted(pkgInfo, signatures.last())) {
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
val extension = Extension.Untrusted(
extName,
pkgName,
@@ -806,6 +806,11 @@ class EHentai(
override fun pageListParse(response: Response) =
throw UnsupportedOperationException("Unused method was called somehow!")
override suspend fun getImageUrl(page: Page): String {
val imageUrlResponse = client.newCall(imageUrlRequest(page)).awaitSuccess()
return realImageUrlParse(imageUrlResponse, page)
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
override fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page))
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
@@ -195,7 +196,9 @@ class ExtensionsScreenModel(
}
fun trustExtension(extension: Extension.Untrusted) {
extensionManager.trust(extension)
screenModelScope.launch {
extensionManager.trust(extension)
}
}
@Immutable
@@ -17,6 +17,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -154,7 +155,7 @@ fun AutoCompleteTextField(
null
},
modifier = Modifier
.menuAnchor()
.menuAnchor(MenuAnchorType.PrimaryEditable)
.fillMaxWidth()
.runOnEnterKeyPressed { submit() },
singleLine = true,
@@ -234,6 +234,9 @@ class LibraryScreenModel(
prefs.filterBookmarked,
prefs.filterCompleted,
prefs.filterIntervalCustom,
// SY -->
prefs.filterLewd,
// SY <--
) + trackFilter.values
).any { it != TriState.DISABLED }
}
@@ -62,6 +62,7 @@ import eu.kanade.presentation.reader.appbars.NavBarType
import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
@@ -1280,7 +1281,9 @@ class ReaderActivity : BaseActivity() {
input.copyTo(output)
}
}
SubsamplingScaleImageView.setDisplayProfile(outputStream.toByteArray())
val data = outputStream.toByteArray()
SubsamplingScaleImageView.setDisplayProfile(data)
TachiyomiImageDecoder.displayProfile = data
}
}
@@ -1141,7 +1141,7 @@ class ReaderViewModel @JvmOverloads constructor(
return imageSaver.save(
image = Image.Page(
inputStream = { ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, 0, bg) },
inputStream = { ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, 0, bg).inputStream() },
name = filename,
location = location,
),
@@ -43,7 +43,9 @@ internal class ZipPageLoader(file: UniFile, context: Context) : PageLoader() {
}
private val apacheZip: ZipFile? = if (!file.isEncryptedZip() && Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
ZipFile(channel)
ZipFile.Builder()
.setSeekableByteChannel(channel)
.get()
} else {
null
}
@@ -18,23 +18,27 @@ import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import coil3.BitmapImage
import coil3.dispose
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.size.Precision
import coil3.size.ViewSizeResolver
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_IN_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.EASE_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
import com.github.chrisbanes.photoview.PhotoView
import eu.kanade.tachiyomi.data.coil.cropBorders
import eu.kanade.tachiyomi.data.coil.customDecoder
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import java.io.InputStream
import java.nio.ByteBuffer
import okio.BufferedSource
/**
* A wrapper view for showing page image.
@@ -140,14 +144,14 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
}
fun setImage(inputStream: InputStream, isAnimated: Boolean, config: Config) {
fun setImage(source: BufferedSource, isAnimated: Boolean, config: Config) {
this.config = config
if (isAnimated) {
prepareAnimatedImageView()
setAnimatedImage(inputStream, config)
setAnimatedImage(source, config)
} else {
prepareNonAnimatedImageView()
setNonAnimatedImage(inputStream, config)
setNonAnimatedImage(source, config)
}
}
@@ -256,7 +260,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
private fun setNonAnimatedImage(
image: Any,
data: Any,
config: Config,
) = (pageView as? SubsamplingScaleImageView)?.apply {
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
@@ -277,12 +281,36 @@ open class ReaderPageImageView @JvmOverloads constructor(
},
)
when (image) {
is BitmapDrawable -> setImage(ImageSource.bitmap(image.bitmap))
is InputStream -> setImage(ImageSource.inputStream(image))
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}")
if (isWebtoon) {
val request = ImageRequest.Builder(context)
.data(data)
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.DISABLED)
.target(
onSuccess = { result ->
val image = result as BitmapImage
setImage(ImageSource.bitmap(image.bitmap))
isVisible = true
},
onError = {
this@ReaderPageImageView.onImageLoadError()
},
)
.size(ViewSizeResolver(this@ReaderPageImageView))
.precision(Precision.INEXACT)
.cropBorders(config.cropBorders)
.customDecoder(true)
.crossfade(false)
.build()
context.imageLoader.enqueue(request)
} else {
when (data) {
is BitmapDrawable -> setImage(ImageSource.bitmap(data.bitmap))
is BufferedSource -> setImage(ImageSource.inputStream(data.inputStream()))
else -> throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
}
isVisible = true
}
isVisible = true
}
private fun prepareAnimatedImageView() {
@@ -325,18 +353,13 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
private fun setAnimatedImage(
image: Any,
data: Any,
config: Config,
) = (pageView as? AppCompatImageView)?.apply {
if (this is PhotoView) {
setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration())
}
val data = when (image) {
is Drawable -> image
is InputStream -> ByteBuffer.wrap(image.readBytes())
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}")
}
val request = ImageRequest.Builder(context)
.data(data)
.memoryCachePolicy(CachePolicy.DISABLED)
@@ -19,15 +19,14 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.math.max
/**
@@ -159,53 +158,45 @@ class PagerPageHolder(
val streamFn2 = extraPage?.stream
try {
val (bais, isAnimated, background) = withIOContext {
streamFn().buffered(16).use { stream ->
val (source, isAnimated, background) = withIOContext {
streamFn().buffered(16).use { source ->
// SY -->
(
if (extraPage != null) {
streamFn2?.invoke()
?.buffered(16)
if (extraPage != null) {
streamFn2?.invoke()
?.buffered(16)
} else {
null
}.use { source2 ->
val itemSource = if (viewer.config.dualPageSplit) {
process(item.first, Buffer().readFrom(source))
} else {
mergePages(Buffer().readFrom(source), source2?.let { Buffer().readFrom(it) })
}
// SY <--
val isAnimated = ImageUtil.isAnimatedAndSupported(itemSource)
val background = if (!isAnimated && viewer.config.automaticBackground) {
ImageUtil.chooseBackground(context, itemSource.peek())
} else {
null
}
).use { stream2 ->
if (viewer.config.dualPageSplit) {
process(item.first, stream)
} else {
mergePages(stream, stream2)
}.use { itemStream ->
// SY <--
val bais = ByteArrayInputStream(itemStream.readBytes())
val isAnimated = ImageUtil.isAnimatedAndSupported(bais)
bais.reset()
val background = if (!isAnimated && viewer.config.automaticBackground) {
ImageUtil.chooseBackground(context, bais)
} else {
null
}
bais.reset()
Triple(bais, isAnimated, background)
}
}
Triple(itemSource, isAnimated, background)
}
}
}
withUIContext {
bais.use {
setImage(
it,
isAnimated,
Config(
zoomDuration = viewer.config.doubleTapAnimDuration,
minimumScaleType = viewer.config.imageScaleType,
cropBorders = viewer.config.imageCropBorders,
zoomStartPosition = viewer.config.imageZoomType,
landscapeZoom = viewer.config.landscapeZoom,
),
)
if (!isAnimated) {
pageBackground = background
}
setImage(
source,
isAnimated,
Config(
zoomDuration = viewer.config.doubleTapAnimDuration,
minimumScaleType = viewer.config.imageScaleType,
cropBorders = viewer.config.imageCropBorders,
zoomStartPosition = viewer.config.imageZoomType,
landscapeZoom = viewer.config.landscapeZoom,
),
)
if (!isAnimated) {
pageBackground = background
}
removeErrorLayout()
}
@@ -217,124 +208,119 @@ class PagerPageHolder(
}
}
private fun process(page: ReaderPage, imageStream: BufferedInputStream): InputStream {
private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource {
if (viewer.config.dualPageRotateToFit) {
return rotateDualPage(imageStream)
return rotateDualPage(imageSource)
}
if (!viewer.config.dualPageSplit) {
return imageStream
return imageSource
}
if (page is InsertPage) {
return splitInHalf(imageStream)
return splitInHalf(imageSource)
}
val isDoublePage = ImageUtil.isWideImage(imageStream)
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) {
return imageStream
return imageSource
}
onPageSplit(page)
return splitInHalf(imageStream)
return splitInHalf(imageSource)
}
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
val isDoublePage = ImageUtil.isWideImage(imageStream)
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation)
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageStream
imageSource
}
}
private fun mergePages(imageStream: InputStream, imageStream2: InputStream?): InputStream {
private fun mergePages(imageSource: BufferedSource, imageSource2: BufferedSource?): BufferedSource {
// Handle adding a center margin to wide images if requested
if (imageStream2 == null) {
return if (imageStream is BufferedInputStream &&
!ImageUtil.isAnimatedAndSupported(imageStream) &&
ImageUtil.isWideImage(imageStream) &&
if (imageSource2 == null) {
return if (
!ImageUtil.isAnimatedAndSupported(imageSource) &&
ImageUtil.isWideImage(imageSource) &&
viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
!viewer.config.imageCropBorders
) {
ImageUtil.addHorizontalCenterMargin(imageStream, height, context)
ImageUtil.addHorizontalCenterMargin(imageSource, height, context)
} else {
imageStream
imageSource
}
}
if (page.fullPage) return imageStream
if (ImageUtil.isAnimatedAndSupported(imageStream)) {
if (page.fullPage) return imageSource
if (ImageUtil.isAnimatedAndSupported(imageSource)) {
page.fullPage = true
splitDoublePages()
return imageStream
} else if (ImageUtil.isAnimatedAndSupported(imageStream2)) {
return imageSource
} else if (ImageUtil.isAnimatedAndSupported(imageSource2)) {
page.isolatedPage = true
extraPage?.fullPage = true
splitDoublePages()
return imageStream
return imageSource
}
val imageBytes = imageStream.readBytes()
val imageBitmap = try {
ImageDecoder.newInstance(imageBytes.inputStream())?.decode()
ImageDecoder.newInstance(imageSource.inputStream())?.decode()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Cannot combine pages" }
null
}
if (imageBitmap == null) {
imageStream2.close()
imageStream.close()
imageSource2.close()
page.fullPage = true
splitDoublePages()
logcat(LogPriority.ERROR) { "Cannot combine pages" }
return imageBytes.inputStream()
return imageSource
}
scope.launch { progressIndicator.setProgress(96) }
val height = imageBitmap.height
val width = imageBitmap.width
if (height < width) {
imageStream2.close()
imageStream.close()
imageSource2.close()
page.fullPage = true
splitDoublePages()
return imageBytes.inputStream()
return imageSource
}
val imageBytes2 = imageStream2.readBytes()
val imageBitmap2 = try {
ImageDecoder.newInstance(imageBytes2.inputStream())?.decode()
ImageDecoder.newInstance(imageSource2.inputStream())?.decode()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Cannot combine pages" }
null
}
if (imageBitmap2 == null) {
imageStream2.close()
imageStream.close()
imageSource2.close()
extraPage?.fullPage = true
page.isolatedPage = true
splitDoublePages()
logcat(LogPriority.ERROR) { "Cannot combine pages" }
return imageBytes.inputStream()
return imageSource
}
scope.launch { progressIndicator.setProgress(97) }
val height2 = imageBitmap2.height
val width2 = imageBitmap2.width
if (height2 < width2) {
imageStream2.close()
imageStream.close()
imageSource2.close()
extraPage?.fullPage = true
page.isolatedPage = true
splitDoublePages()
return imageBytes.inputStream()
return imageSource
}
val isLTR = (viewer !is R2LPagerViewer) xor viewer.config.invertDoublePages
imageStream.close()
imageStream2.close()
imageSource.close()
imageSource2.close()
val centerMargin = if (viewer.config.centerMarginType and PagerConfig.CenterMarginType.DOUBLE_PAGE_CENTER_MARGIN > 0 && !viewer.config.imageCropBorders) {
96 / (this.height.coerceAtLeast(1) / max(height, height2).coerceAtLeast(1)).coerceAtLeast(1)
@@ -363,7 +349,7 @@ class PagerPageHolder(
}
}
private fun splitInHalf(imageStream: InputStream): InputStream {
private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
var side = when {
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT
@@ -387,7 +373,7 @@ class PagerPageHolder(
0
}
return ImageUtil.splitInHalf(imageStream, side, sideMargin)
return ImageUtil.splitInHalf(imageSource, side, sideMargin)
}
private fun onPageSplit(page: ReaderPage) {
@@ -22,15 +22,14 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.suspendCancellableCoroutine
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import java.io.BufferedInputStream
import java.io.InputStream
/**
* Holder of the webtoon reader for a single page of a chapter.
@@ -188,16 +187,14 @@ class WebtoonPageHolder(
val streamFn = page?.stream ?: return
try {
val (openStream, isAnimated) = withIOContext {
val stream = streamFn().buffered(16)
val openStream = process(stream)
val isAnimated = ImageUtil.isAnimatedAndSupported(stream)
Pair(openStream, isAnimated)
val (source, isAnimated) = withIOContext {
val source = streamFn().use { process(Buffer().readFrom(it)) }
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
Pair(source, isAnimated)
}
withUIContext {
frame.setImage(
openStream,
source,
isAnimated,
ReaderPageImageView.Config(
zoomDuration = viewer.config.doubleTapAnimDuration,
@@ -207,10 +204,6 @@ class WebtoonPageHolder(
)
removeErrorLayout()
}
// Suspend the coroutine to close the input stream only when the WebtoonPageHolder is recycled
suspendCancellableCoroutine<Nothing> { continuation ->
continuation.invokeOnCancellation { openStream.close() }
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
withUIContext {
@@ -219,29 +212,29 @@ class WebtoonPageHolder(
}
}
private fun process(imageStream: BufferedInputStream): InputStream {
private fun process(imageSource: BufferedSource): BufferedSource {
if (viewer.config.dualPageRotateToFit) {
return rotateDualPage(imageStream)
return rotateDualPage(imageSource)
}
if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageStream)
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) {
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageStream, upperSide)
return ImageUtil.splitAndMerge(imageSource, upperSide)
}
}
return imageStream
return imageSource
}
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
val isDoublePage = ImageUtil.isWideImage(imageStream)
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation)
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageStream
imageSource
}
}
@@ -20,12 +20,13 @@ class CrashLogUtil(
private val extensionManager: ExtensionManager = Injekt.get(),
) {
suspend fun dumpLogs() = withNonCancellableContext {
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
try {
val file = context.createFileInCacheDir("tachiyomi_crash_logs.txt")
file.appendText(getDebugInfo() + "\n\n")
getExtensionsInfo()?.let { file.appendText("$it\n\n") }
exception?.let { file.appendText("$it\n\n") }
Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}").waitFor()
@@ -17,10 +17,13 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.BufferedSource
import okio.buffer
import okio.sink
import okio.source
import java.io.Closeable
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
import java.nio.ByteBuffer
import kotlin.concurrent.thread
@@ -67,7 +70,7 @@ class MemAutoFlushingLookupTable<T>(
Runtime.getRuntime().addShutdownHook(shutdownHook)
}
private fun InputStream.requireBytes(targetArray: ByteArray, byteCount: Int): Boolean {
private fun BufferedSource.requireBytes(targetArray: ByteArray, byteCount: Int): Boolean {
var readIter = 0
while (true) {
val readThisIter = read(targetArray, readIter, byteCount - readIter)
@@ -80,7 +83,7 @@ class MemAutoFlushingLookupTable<T>(
private fun initialLoad() {
launch {
try {
atomicFile.openRead().buffered().use { input ->
atomicFile.openRead().source().buffer().use { input ->
val bb = ByteBuffer.allocate(8)
while (true) {
@@ -126,7 +129,7 @@ class MemAutoFlushingLookupTable<T>(
val fos = atomicFile.startWrite()
try {
val out = fos.buffered()
val out = fos.sink().buffer()
table.forEach { key, value ->
val v = serializer.write(value).toByteArray(Charsets.UTF_8)
bb.putInt(0, key)
@@ -76,7 +76,7 @@ class BatchAddScreen : Screen() {
text = stringResource(SYMR.strings.eh_batch_add_description),
)
},
keyboardOptions = KeyboardOptions(autoCorrect = false),
keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
textStyle = MaterialTheme.typography.bodyLarge,
)
@@ -26,9 +26,9 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Zip file of this epub.
*/
private val zip = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
ZipFile(tempFileManager.createTempFile(file))
ZipFile.Builder().setFile(tempFileManager.createTempFile(file)).get()
} else {
ZipFile(file.openReadOnlyChannel(context))
ZipFile.Builder().setSeekableByteChannel(file.openReadOnlyChannel(context)).get()
}
// SY <--
@@ -26,11 +26,10 @@ import androidx.core.graphics.red
import androidx.exifinterface.media.ExifInterface
import com.hippo.unifile.UniFile
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.net.URLConnection
@@ -83,9 +82,9 @@ object ImageUtil {
?: "jpg"
}
fun isAnimatedAndSupported(stream: InputStream): Boolean {
fun isAnimatedAndSupported(source: BufferedSource): Boolean {
return try {
val type = getImageType(stream) ?: return false
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
@@ -132,18 +131,16 @@ object ImageUtil {
*
* @return true if the width is greater than the height
*/
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
fun isWideImage(imageSource: BufferedSource): Boolean {
val options = extractImageOptions(imageSource)
return options.outWidth > options.outHeight
}
/**
* Extract the 'side' part from imageStream and return it as InputStream.
* Extract the 'side' part from [BufferedSource] and return it as [BufferedSource].
*/
fun splitInHalf(imageStream: InputStream, side: Side, sidePadding: Int): InputStream {
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
fun splitInHalf(imageSource: BufferedSource, side: Side, sidePadding: Int): BufferedSource {
val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
val height = imageBitmap.height
val width = imageBitmap.width
@@ -157,22 +154,20 @@ object ImageUtil {
half.applyCanvas {
drawBitmap(imageBitmap, part, singlePage, null)
}
val output = ByteArrayOutputStream()
half.compress(Bitmap.CompressFormat.JPEG, 100, output)
val output = Buffer()
half.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return ByteArrayInputStream(output.toByteArray())
return output
}
fun rotateImage(imageStream: InputStream, degrees: Float): InputStream {
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
fun rotateImage(imageSource: BufferedSource, degrees: Float): BufferedSource {
val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
val rotated = rotateBitMap(imageBitmap, degrees)
val output = ByteArrayOutputStream()
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output)
val output = Buffer()
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return ByteArrayInputStream(output.toByteArray())
return output
}
private fun rotateBitMap(bitmap: Bitmap, degrees: Float): Bitmap {
@@ -184,10 +179,8 @@ object ImageUtil {
* Split the image into left and right parts, then merge them into a
* new vertically-aligned image.
*/
fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream {
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
fun splitAndMerge(imageSource: BufferedSource, upperSide: Side): BufferedSource {
val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
val height = imageBitmap.height
val width = imageBitmap.width
@@ -209,9 +202,9 @@ object ImageUtil {
drawBitmap(imageBitmap, leftPart, bottomPart, null)
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
return ByteArrayInputStream(output.toByteArray())
val output = Buffer()
result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return output
}
enum class Side {
@@ -225,8 +218,8 @@ object ImageUtil {
* to compensate for scaling.
*/
fun addHorizontalCenterMargin(imageStream: InputStream, viewHeight: Int, backgroundContext: Context): InputStream {
val imageBitmap = ImageDecoder.newInstance(imageStream)?.decode()!!
fun addHorizontalCenterMargin(imageSource: BufferedSource, viewHeight: Int, backgroundContext: Context): BufferedSource {
val imageBitmap = ImageDecoder.newInstance(imageSource.inputStream())?.decode()!!
val height = imageBitmap.height
val width = imageBitmap.width
@@ -237,7 +230,7 @@ object ImageUtil {
val leftTargetPart = Rect(0, 0, width / 2, height)
val rightTargetPart = Rect(width / 2 + centerPadding, 0, width + centerPadding, height)
val bgColor = chooseBackground(backgroundContext, imageStream)
val bgColor = chooseBackground(backgroundContext, imageSource)
bgColor.setBounds(width / 2, 0, width / 2 + centerPadding, height)
val result = createBitmap(width + centerPadding, height)
@@ -247,9 +240,9 @@ object ImageUtil {
bgColor.draw(this)
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
return ByteArrayInputStream(output.toByteArray())
val output = Buffer()
result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return output
}
// SY <--
@@ -258,11 +251,8 @@ object ImageUtil {
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(
imageStream,
resetAfterExtraction = false,
)
private fun isTallImage(imageSource: BufferedSource): Boolean {
val options = extractImageOptions(imageSource)
return (options.outHeight / options.outWidth) > 3
}
@@ -275,22 +265,18 @@ object ImageUtil {
imageFile: UniFile,
filenamePrefix: String,
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) ||
!isTallImage(imageFile.openInputStream())
) {
val imageSource = imageFile.openInputStream().use { Buffer().readFrom(it) }
if (isAnimatedAndSupported(imageSource) || !isTallImage(imageSource)) {
return true
}
val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream())
val bitmapRegionDecoder = getBitmapRegionDecoder(imageSource.peek().inputStream())
if (bitmapRegionDecoder == null) {
logcat { "Failed to create new instance of BitmapRegionDecoder" }
return false
}
val options = extractImageOptions(
imageFile.openInputStream(),
resetAfterExtraction = false,
).apply {
val options = extractImageOptions(imageSource).apply {
inJustDecodeBounds = false
}
@@ -380,8 +366,8 @@ object ImageUtil {
/**
* Algorithm for determining what background to accompany a comic/manga page
*/
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
val decoder = ImageDecoder.newInstance(imageStream)
fun chooseBackground(context: Context, imageSource: BufferedSource): Drawable {
val decoder = ImageDecoder.newInstance(imageSource.inputStream())
val image = decoder?.decode()
decoder?.recycle()
@@ -603,16 +589,9 @@ object ImageUtil {
/**
* Used to check an image's dimensions without loading it in the memory.
*/
private fun extractImageOptions(
imageStream: InputStream,
resetAfterExtraction: Boolean = true,
): BitmapFactory.Options {
imageStream.mark(Int.MAX_VALUE)
val imageBytes = imageStream.readBytes()
private fun extractImageOptions(imageSource: BufferedSource): BitmapFactory.Options {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
if (resetAfterExtraction) imageStream.reset()
BitmapFactory.decodeStream(imageSource.peek().inputStream(), null, options)
return options
}
@@ -657,7 +636,7 @@ object ImageUtil {
centerMargin: Int,
@ColorInt background: Int = Color.WHITE,
progressCallback: ((Int) -> Unit)? = null,
): ByteArrayInputStream {
): BufferedSource {
val height = imageBitmap.height
val width = imageBitmap.width
val height2 = imageBitmap2.height
@@ -687,10 +666,10 @@ object ImageUtil {
canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null)
progressCallback?.invoke(99)
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
val output = Buffer()
result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
progressCallback?.invoke(100)
return ByteArrayInputStream(output.toByteArray())
return output
}
private val Bitmap.rect: Rect
+5 -5
View File
@@ -1,5 +1,5 @@
[versions]
agp_version = "8.3.1"
agp_version = "8.4.0"
lifecycle_version = "2.7.0"
paging_version = "3.2.1"
@@ -10,7 +10,7 @@ annotation = "androidx.annotation:annotation:1.7.1"
appcompat = "androidx.appcompat:appcompat:1.6.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
corektx = "androidx.core:core-ktx:1.12.0"
corektx = "androidx.core:core-ktx:1.13.1"
splashscreen = "androidx.core:core-splashscreen:1.0.1"
recyclerview = "androidx.recyclerview:recyclerview:1.3.2"
viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01"
@@ -25,9 +25,9 @@ workmanager = "androidx.work:work-runtime:2.9.0"
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" }
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.3"
test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha03"
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha03"
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.4"
test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha04"
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha04"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"
[bundles]
+3 -3
View File
@@ -1,12 +1,12 @@
[versions]
compiler = "1.5.11"
compose-bom = "2024.02.00-alpha02"
compiler = "1.5.12"
compose-bom = "2024.05.00-alpha01"
accompanist = "0.35.0-alpha"
[libraries]
compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compiler" }
activity = "androidx.activity:activity-compose:1.8.2"
activity = "androidx.activity:activity-compose:1.9.0"
bom = { group = "dev.chrisbanes.compose", name = "compose-bom", version.ref = "compose-bom" }
foundation = { module = "androidx.compose.foundation:foundation" }
animation = { module = "androidx.compose.animation:animation" }
+5 -9
View File
@@ -1,7 +1,6 @@
[versions]
aboutlib_version = "11.1.1"
acra = "5.11.3"
leakcanary = "2.13"
aboutlib_version = "11.1.4"
leakcanary = "2.14"
moko = "0.23.0"
okhttp_version = "5.0.0-alpha.12"
richtext = "0.20.0"
@@ -32,7 +31,7 @@ quickjs-android = "app.cash.quickjs:quickjs-android:0.9.2"
jsoup = "org.jsoup:jsoup:1.17.2"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:7c257e1c64"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
common-compress = "org.apache.commons:commons-compress:1.26.1"
junrar = "com.github.junrar:junrar:7.5.5"
zip4j = "net.lingala.zip4j:zip4j:2.11.5"
@@ -52,7 +51,7 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp" }
subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:aeaa170036"
subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:b8e1b0ed2b"
image-decoder = "com.github.tachiyomiorg:image-decoder:e08e9be535"
exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
@@ -77,9 +76,7 @@ moko-gradle = { module = "dev.icerock.moko:resources-generator", version.ref = "
logcat = "com.squareup.logcat:logcat:0.1"
acra-http = { module = "ch.acra:acra-http", version.ref = "acra" }
acra-scheduler = { module = "ch.acra:acra-advanced-scheduler", version.ref = "acra" }
firebase-analytics = "com.google.firebase:firebase-analytics-ktx:21.6.1"
firebase-analytics = "com.google.firebase:firebase-analytics:22.0.0"
aboutLibraries-gradle = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutlib_version" }
aboutLibraries-compose = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlib_version" }
@@ -113,7 +110,6 @@ google-api-services-drive = "com.google.apis:google-api-services-drive:v3-rev197
google-api-client-oauth = "com.google.oauth-client:google-oauth-client:1.34.1"
[bundles]
acra = ["acra-http", "acra-scheduler"]
archive = ["common-compress", "junrar"]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"]
+3 -3
View File
@@ -1,8 +1,8 @@
[versions]
[libraries]
firebase-analytics = "com.google.firebase:firebase-analytics-ktx:21.6.1"
firebase-crashlytics-ktx = "com.google.firebase:firebase-crashlytics-ktx:18.6.3"
firebase-analytics = "com.google.firebase:firebase-analytics:22.0.0"
firebase-crashlytics-ktx = "com.google.firebase:firebase-crashlytics:19.0.0"
firebase-crashlytics-gradle = "com.google.firebase:firebase-crashlytics-gradle:2.9.9"
simularity = "info.debatty:java-string-similarity:2.0.0"
@@ -11,4 +11,4 @@ xlog = "com.elvishew:xlog:1.11.0"
ratingbar = "me.zhanghai.android.materialratingbar:library:1.4.0"
composeRatingbar = "com.github.a914-gowtham:compose-ratingbar:1.2.3"
versionsx = "com.github.ben-manes:gradle-versions-plugin:0.42.0"
versionsx = "com.github.ben-manes:gradle-versions-plugin:0.51.0"
@@ -203,10 +203,8 @@
<string name="pref_local_source_hidden_folders_summery">Allow local source to read hidden folders</string>
<!-- Backup settings -->
<string name="pref_backup_and_sync_summary">Manual &amp; automatic backups and sync</string>
<string name="custom_entry_info">Custom entry info</string>
<string name="all_read_entries">All read entries</string>
<string name="label_backup">Backup</string>
<string name="label_sync">Sync</string>
<string name="label_triggers">Triggers</string>
@@ -220,24 +218,16 @@
<string name="pref_sync_api_key_summ">Enter the API key to synchronize your library</string>
<string name="pref_sync_now_group_title">Sync Actions</string>
<string name="pref_sync_now">Sync now</string>
<string name="pref_sync_confirmation_title">Sync confirmation</string>
<string name="pref_sync_now_subtitle">Initiate immediate synchronization of your data</string>
<string name="pref_sync_confirmation_message">Syncing will overwrite your local library with the remote library. Are you sure you want to continue?</string>
<string name="pref_sync_service">Service</string>
<string name="pref_sync_service_summ">Select the service to sync your library with</string>
<string name="pref_sync_service_category">Sync</string>
<string name="pref_sync_automatic_category">Automatic Synchronization</string>
<string name="pref_sync_interval">Synchronization frequency</string>
<string name="pref_choose_what_to_sync">Choose what to sync</string>
<string name="success_reset_sync_timestamp">Last sync timestamp reset</string>
<string name="syncyomi">SyncYomi</string>
<string name="sync_completed_message">Done in %1$s</string>
<string name="last_synchronization">Last Synchronization: %1$s</string>
<string name="google_drive">Google Drive</string>
<string name="pref_google_drive_sign_in">Sign in</string>
<string name="google_drive_sign_in_success">Signed in successfully</string>
<string name="google_drive_sign_in_failed">Sign in failed</string>
<string name="authentication">Authentication</string>
<string name="pref_google_drive_purge_sync_data">Clear Sync Data from Google Drive</string>
<string name="google_drive_sync_data_purged">Sync data purged from Google Drive</string>
<string name="google_drive_sync_data_not_found">No sync data found in Google Drive</string>
@@ -179,6 +179,9 @@
<string name="pref_mark_read_dupe_chapters_summary">Помечать повторяющиеся главы как «Прочитано» после прочтения</string>
<string name="pref_library_mark_duplicate_chapters">Помечать новые повторяющиеся главы как «Прочитано»</string>
<string name="pref_library_mark_duplicate_chapters_summary">Автоматически помечать новые главы как «Прочитано», если они были ранее прочитаны</string>
<string name="update_30min">Каждые 30 минут</string>
<string name="update_1hour">Каждый час</string>
<string name="update_3hour">Каждые 3 часа</string>
<!-- Browse settings -->
<string name="pref_hide_feed">Скрыть вкладку «Лента»</string>
@@ -194,6 +197,47 @@
<!-- Backup settings -->
<string name="custom_entry_info">Сведенья пользователя</string>
<string name="all_read_entries">Прочитанные серии</string>
<string name="label_sync">Синхронизировать</string>
<string name="label_triggers">Триггеры</string>
<!-- Sync settings -->
<string name="sync_error">Не удалось синхронизировать библиотеку</string>
<string name="sync_complete">Синхронизация библиотеки завершена</string>
<string name="sync_in_progress">Синхронизация уже выполняется</string>
<string name="pref_sync_host">Хост</string>
<string name="pref_sync_host_summ">Введите адрес хоста для синхронизации вашей библиотеки</string>
<string name="pref_sync_api_key">Ключ API</string>
<string name="pref_sync_api_key_summ">Введите API ключ для синхронизации вашей библиотеки</string>
<string name="pref_sync_now_group_title">Действия синхронизации</string>
<string name="pref_sync_now">Синхронизировать сейчас</string>
<string name="pref_sync_now_subtitle">Начать немедленную синхронизацию ваших данных</string>
<string name="pref_sync_service">Сервис</string>
<string name="pref_sync_service_category">Синхронизация</string>
<string name="pref_sync_automatic_category">Автоматическая синхронизация</string>
<string name="pref_sync_interval">Частота синхронизации</string>
<string name="pref_choose_what_to_sync">Выберите, что синхронизировать</string>
<string name="last_synchronization">Последняя синхронизация: %1$s</string>
<string name="google_drive">Google Диск</string>
<string name="pref_google_drive_sign_in">Войти в Google Диск</string>
<string name="pref_google_drive_purge_sync_data">Очистить данные синхронизации с Google Диска</string>
<string name="google_drive_sync_data_purged">Данные синхронизации удалены из Google Диска</string>
<string name="google_drive_sync_data_not_found">Данные синхронизации не найдены в Google Диске</string>
<string name="google_drive_sync_data_purge_error">Ошибка при очистке данных синхронизации с Google Диска, попробуйте войти снова.</string>
<string name="google_drive_login_success">Выполнен вход в Google Диск</string>
<string name="google_drive_login_failed">Не удалось войти в Google Диск: %s</string>
<string name="google_drive_not_signed_in">Не выполнен вход в Google Диск</string>
<string name="error_uploading_sync_data">Ошибка при загрузке данных синхронизации на Google Диск</string>
<string name="error_deleting_google_drive_lock_file">Ошибка удаления файла блокировки Google Диска</string>
<string name="error_before_sync_gdrive">Ошибка перед синхронизацией: %s</string>
<string name="pref_purge_confirmation_title">Подтверждение очистки</string>
<string name="pref_purge_confirmation_message">Очистка данных синхронизации приведет к удалению всех данных синхронизации с Google Диска. Вы уверены, что хотите продолжить?</string>
<string name="pref_sync_options">Создать триггеры синхронизации</string>
<string name="pref_sync_options_summ">Можно использовать для настройки триггеров синхронизации</string>
<string name="sync_on_chapter_read">Синхронизовать при прочтении главы</string>
<string name="sync_on_chapter_open">Синхронизовать при открытии главы</string>
<string name="sync_on_app_start">Синхронизовать при запуске приложения</string>
<string name="sync_on_app_resume">Синхронизовать при возвращении в приложение</string>
<string name="sync_library">Синхронизировать библиотеку</string>
<!-- Security settings -->
<string name="biometric_lock_times">Биометрическое время блокировки</string>
@@ -10,7 +10,6 @@ import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -78,8 +77,7 @@ fun AdaptiveSheet(
Box(
modifier = Modifier
.clickable(
enabled = true,
interactionSource = remember { MutableInteractionSource() },
interactionSource = null,
indication = null,
onClick = internalOnDismissRequest,
)
@@ -91,7 +89,7 @@ fun AdaptiveSheet(
modifier = Modifier
.requiredWidthIn(max = 460.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
interactionSource = null,
indication = null,
onClick = {},
)
@@ -122,14 +120,14 @@ fun AdaptiveSheet(
)
}
val internalOnDismissRequest = {
if (anchoredDraggableState.currentValue == 0) {
if (anchoredDraggableState.settledValue == 0) {
scope.launch { anchoredDraggableState.animateTo(1) }
}
}
Box(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
interactionSource = null,
indication = null,
onClick = internalOnDismissRequest,
)
@@ -147,7 +145,7 @@ fun AdaptiveSheet(
modifier = Modifier
.widthIn(max = 460.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
interactionSource = null,
indication = null,
onClick = {},
)
@@ -155,7 +153,9 @@ fun AdaptiveSheet(
if (enableSwipeDismiss) {
Modifier.nestedScroll(
remember(anchoredDraggableState) {
anchoredDraggableState.preUpPostDownNestedScrollConnection()
anchoredDraggableState.preUpPostDownNestedScrollConnection(
onFling = { scope.launch { anchoredDraggableState.settle(it) } }
)
},
)
} else {
@@ -192,7 +192,7 @@ fun AdaptiveSheet(
LaunchedEffect(anchoredDraggableState) {
scope.launch { anchoredDraggableState.animateTo(0) }
snapshotFlow { anchoredDraggableState.currentValue }
snapshotFlow { anchoredDraggableState.settledValue }
.drop(1)
.filter { it == 1 }
.collectLatest {
@@ -203,55 +203,51 @@ fun AdaptiveSheet(
}
}
private fun <T> AnchoredDraggableState<T>.preUpPostDownNestedScrollConnection() =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
private fun <T> AnchoredDraggableState<T>.preUpPostDownNestedScrollConnection(
onFling: (velocity: Float) -> Unit
) = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.UserInput) {
dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.Drag) {
dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
return if (toFling < 0 && offset > anchors.minAnchor()) {
settle(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val toFling = available.toFloat()
return if (toFling > 0) {
settle(toFling)
available
} else {
Velocity.Zero
}
}
private fun Float.toOffset(): Offset = Offset(0f, this)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = this.y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = this.y
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.UserInput) {
dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
return if (toFling < 0 && offset > anchors.minAnchor()) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = this.y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = this.y
}
@@ -1,34 +1,16 @@
package tachiyomi.presentation.core.components.material
import androidx.compose.animation.core.animate
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.pow
/**
* @param refreshing Whether the layout is currently refreshing
@@ -46,242 +28,26 @@ fun PullRefresh(
indicatorPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable () -> Unit,
) {
val state = rememberPullToRefreshState(
isRefreshing = refreshing,
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
enabled = enabled,
onRefresh = onRefresh,
)
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
val state = rememberPullToRefreshState()
Box(
modifier = modifier
.pullToRefresh(
isRefreshing = refreshing,
state = state,
enabled = enabled,
onRefresh = onRefresh,
)
) {
content()
val contentPadding = remember(indicatorPadding) {
object : PaddingValues {
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateLeftPadding(layoutDirection)
override fun calculateTopPadding(): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateRightPadding(layoutDirection)
override fun calculateBottomPadding(): Dp =
indicatorPadding.calculateBottomPadding()
}
}
PullToRefreshContainer(
state = state,
PullToRefreshDefaults.Indicator(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(contentPadding),
.padding(indicatorPadding),
isRefreshing = refreshing,
state = state,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun rememberPullToRefreshState(
isRefreshing: Boolean,
extraVerticalOffset: Dp,
positionalThreshold: Dp = 64.dp,
enabled: () -> Boolean = { true },
onRefresh: () -> Unit,
): PullToRefreshStateImpl {
val density = LocalDensity.current
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
return rememberSaveable(
extraVerticalOffset,
positionalThresholdPx,
enabled,
onRefresh,
saver = PullToRefreshStateImpl.Saver(
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
onRefresh = onRefresh,
),
) {
PullToRefreshStateImpl(
initialRefreshing = isRefreshing,
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
onRefresh = onRefresh,
)
}.also {
LaunchedEffect(isRefreshing) {
if (isRefreshing && !it.isRefreshing) {
it.startRefreshAnimated()
} else if (!isRefreshing && it.isRefreshing) {
it.endRefreshAnimated()
}
}
}
}
/**
* Creates a [PullToRefreshState].
*
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
* @param enabled a callback used to determine whether scroll events are to be handled by this
* @param onRefresh a callback to run when pull-to-refresh action is triggered by user
* [PullToRefreshState]
*/
private class PullToRefreshStateImpl(
initialRefreshing: Boolean,
private val extraVerticalOffset: Float,
override val positionalThreshold: Float,
enabled: () -> Boolean,
private val onRefresh: () -> Unit,
) : PullToRefreshState {
override val progress get() = adjustedDistancePulled / positionalThreshold
override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
override var isRefreshing by mutableStateOf(initialRefreshing)
private val refreshingVerticalOffset: Float
get() = positionalThreshold + extraVerticalOffset
override fun startRefresh() {
isRefreshing = true
verticalOffset = refreshingVerticalOffset
}
suspend fun startRefreshAnimated() {
isRefreshing = true
animateTo(refreshingVerticalOffset)
}
override fun endRefresh() {
verticalOffset = 0f
isRefreshing = false
}
suspend fun endRefreshAnimated() {
animateTo(0f)
isRefreshing = false
}
override var nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping up
source == NestedScrollSource.Drag && available.y < 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping down
source == NestedScrollSource.Drag && available.y > 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
}
/** Helper method for nested scroll connection */
fun consumeAvailableOffset(available: Offset): Offset {
val y = if (isRefreshing) {
0f
} else {
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
dragConsumed
}
return Offset(0f, y)
}
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
suspend fun onRelease(velocity: Float): Float {
if (isRefreshing) return 0f // Already refreshing, do nothing
// Trigger refresh
if (adjustedDistancePulled > positionalThreshold) {
onRefresh()
startRefreshAnimated()
} else {
animateTo(0f)
}
val consumed = when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
suspend fun animateTo(offset: Float) {
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
verticalOffset = value
}
}
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
fun calculateVerticalOffset(): Float = when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = positionalThreshold * tensionPercent
positionalThreshold + extraOffset
}
}
companion object {
/** The default [Saver] for [PullToRefreshStateImpl]. */
fun Saver(
extraVerticalOffset: Float,
positionalThreshold: Float,
enabled: () -> Boolean,
onRefresh: () -> Unit,
) = Saver<PullToRefreshStateImpl, Boolean>(
save = { it.isRefreshing },
restore = { isRefreshing ->
PullToRefreshStateImpl(
initialRefreshing = isRefreshing,
extraVerticalOffset = extraVerticalOffset,
positionalThreshold = positionalThreshold,
enabled = enabled,
onRefresh = onRefresh,
)
},
)
}
private var distancePulled by mutableFloatStateOf(0f)
private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
}
@@ -6,13 +6,13 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ripple
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
@@ -3,63 +3,16 @@ package tachiyomi.presentation.core.util
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun LazyListState.isScrolledToStart(): Boolean {
fun LazyListState.shouldExpandFAB(): Boolean {
return remember {
derivedStateOf {
val firstItem = layoutInfo.visibleItemsInfo.firstOrNull()
firstItem == null || firstItem.offset == layoutInfo.viewportStartOffset
(firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0) ||
lastScrolledBackward ||
!canScrollForward
}
}.value
}
@Composable
fun LazyListState.isScrolledToEnd(): Boolean {
return remember {
derivedStateOf {
val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset
}
}.value
}
@Composable
fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember { mutableIntStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember { mutableIntStateOf(firstVisibleItemScrollOffset) }
return remember {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
@Composable
fun LazyListState.isScrollingDown(): Boolean {
var previousIndex by remember { mutableIntStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember { mutableIntStateOf(firstVisibleItemScrollOffset) }
return remember {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex < firstVisibleItemIndex
} else {
previousScrollOffset <= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
.value
}
@@ -1,7 +1,6 @@
package tachiyomi.presentation.core.util
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
@@ -42,14 +41,12 @@ fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
fun Modifier.clickableNoIndication(
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit,
): Modifier = composed {
Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = onLongClick,
onClick = onClick,
)
}
) = this.combinedClickable(
interactionSource = null,
indication = null,
onLongClick = onLongClick,
onClick = onClick,
)
/**
* For TextField, the provided [action] will be invoked when
@@ -17,16 +17,10 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import logcat.LogPriority
import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.copyFromComicInfo
import tachiyomi.core.metadata.comicinfo.getComicInfo
import tachiyomi.core.metadata.tachiyomi.MangaDetails
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.addStreamToZip
import tachiyomi.core.common.storage.extension
@@ -37,6 +31,12 @@ import tachiyomi.core.common.storage.nameWithoutExtension
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.ComicInfoPublishingStatus
import tachiyomi.core.metadata.comicinfo.copyFromComicInfo
import tachiyomi.core.metadata.comicinfo.getComicInfo
import tachiyomi.core.metadata.tachiyomi.MangaDetails
import tachiyomi.domain.chapter.service.ChapterRecognition
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
@@ -157,17 +157,39 @@ actual class LocalSource(
// SY -->
fun updateMangaInfo(manga: SManga) {
val existingFile = fileSystem.getFilesInMangaDirectory(manga.url).find { it.extension == "json" }
val file = existingFile
?: fileSystem.getMangaDirectory(manga.url)?.createFile("info.json")
?: return
file.openOutputStream().use {
json.encodeToStream(manga.toJson(), it)
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url)
val existingFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE }
val comicInfoArchiveFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_ARCHIVE }
val existingComicInfo = (existingFile?.openInputStream() ?: comicInfoArchiveFile?.getZipInputStream(COMIC_INFO_FILE))?.use {
AndroidXmlReader(it, StandardCharsets.UTF_8.name()).use {
xml.decodeFromReader<ComicInfo>(it)
}
}
val newComicInfo = if (existingComicInfo != null) {
manga.run {
existingComicInfo.copy(
series = ComicInfo.Series(title),
summary = description?.let { ComicInfo.Summary(it) },
writer = author?.let { ComicInfo.Writer(it) },
penciller = artist?.let { ComicInfo.Penciller(it) },
genre = genre?.let { ComicInfo.Genre(it) },
publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(status.toLong()),
),
)
}
} else {
manga.getComicInfo()
}
}
private fun SManga.toJson(): MangaDetails {
return MangaDetails(title, author, artist, description, genre?.split(", "), status)
fileSystem.getMangaDirectory(manga.url)?.let {
copyComicInfoFile(
xml.encodeToString(ComicInfo.serializer(), newComicInfo).byteInputStream(),
it
)
}
}
// SY <--
@@ -368,8 +390,8 @@ actual class LocalSource(
try {
val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2)
return fileSystem.getBaseDirectory()
?.findFile(mangaDirName, true)
?.findFile(chapterName, true)
?.findFile(mangaDirName)
?.findFile(chapterName)
?.let(Format.Companion::valueOf)
?: throw Exception(context.stringResource(MR.strings.chapter_not_found))
} catch (e: Format.UnknownFormatException) {
@@ -17,13 +17,13 @@ actual class LocalSourceFileSystem(
actual fun getMangaDirectory(name: String): UniFile? {
return getBaseDirectory()
?.findFile(name, true)
?.findFile(name)
?.takeIf { it.isDirectory }
}
actual fun getFilesInMangaDirectory(name: String): List<UniFile> {
return getBaseDirectory()
?.findFile(name, true)
?.findFile(name)
?.takeIf { it.isDirectory }
?.listFiles().orEmpty().toList()
}