Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e96895345e | |||
| eec1236b8b | |||
| ee1e783126 | |||
| f3ab39cb1f | |||
| ba75395648 | |||
| fe0b14ab97 | |||
| 91d2140288 | |||
| 0417969dd6 | |||
| 5d8d2ce48a | |||
| b15277f134 | |||
| 76ca27f681 | |||
| 56923c76d4 | |||
| 32e19736b9 | |||
| 11b01b2771 | |||
| 460ff13e54 | |||
| 57f77c8105 | |||
| a2eb22964a | |||
| 7158bae26a | |||
| 807ce846d5 | |||
| 0b68f2c62a | |||
| b7d6cc8dd0 | |||
| 8b1fd30902 | |||
| aff43f3aeb | |||
| 0047d2e5d8 | |||
| d87385f5b3 | |||
| c17e9573b7 | |||
| 9c01119d24 | |||
| bbc839e234 | |||
| 917f20894b | |||
| 3a3b719b8b | |||
| 1903437ecf | |||
| 5c26bb3a52 | |||
| 07599ade3a | |||
| 0a9f36402b | |||
| d2b325cd02 | |||
| cdc64aceb7 | |||
| 4bfd6e4026 | |||
| 50eebdf7d3 | |||
| f843de28d7 | |||
| d250a9a680 | |||
| 4130db3920 | |||
| f2cbff04ab | |||
| 061e9359e8 | |||
| 73258e9e05 | |||
| 73e4982ffb | |||
| 185cd923c0 | |||
| 3cfc53bf11 | |||
| 1301acfdb7 | |||
| 9d9dbea48d | |||
| c1df3eb1d0 | |||
| 3154c97aee | |||
| ffe1b160de | |||
| 23272375b7 | |||
| 863b6ee784 | |||
| c4c8d4b9c3 | |||
| b2bbbca585 | |||
| df3b879cf6 | |||
| 47c4f2cc8c | |||
| 905a1c1230 | |||
| bcaf7f6415 | |||
| 4639b3ecc3 | |||
| 2034971cc0 | |||
| bb8698b2a6 | |||
| cd69b09dd0 | |||
| 462b2164e8 | |||
| fb1a4ad828 | |||
| 3bd89cee26 | |||
| 6f43e98fff | |||
| 6feeb4b1ee | |||
| fcfe750fcf | |||
| 6e314e3643 | |||
| 487ca49c11 | |||
| 698abe8667 | |||
| 13c9daf9a9 | |||
| eb21454d6d | |||
| 56347e6d52 | |||
| 5c085a36e8 | |||
| 65ab676946 | |||
| 1f51569a35 | |||
| b0d6e16ca3 | |||
| 85cf54ccc8 | |||
| 602df5a729 | |||
| c8102836ce | |||
| e641575941 | |||
| 83afcee4d1 | |||
| 2102e0594e | |||
| 14c91da6b3 | |||
| 46c1c6463a | |||
| 89a521b836 | |||
| 65c6ed21ab | |||
| 1b911e7e15 | |||
| 0535e41051 | |||
| 3fc802f837 | |||
| 976b5cc03e | |||
| a9fe971337 | |||
| 5d1dbcb390 | |||
| 8d11ef3244 | |||
| 724a61f513 | |||
| 724c774dc9 | |||
| 29e0b2e4a5 | |||
| 2776e41127 | |||
| af1f77418f | |||
| c1df5da062 | |||
| f8f645772d | |||
| b1e6fa65d6 | |||
| 01e8c6cc12 |
@@ -100,5 +100,5 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
||||||
required: true
|
required: true
|
||||||
- label: I understand that **Mihon does not have or fix any extensions**, and I **will not receive help** for any issues related to sources or extensions.
|
- label: I understand that **TachiyomiSY does not have or fix any extensions**, and I **will not receive help** for any issues related to sources or extensions.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
run: ./gradlew spotlessCheck assembleDevDebug
|
run: ./gradlew spotlessCheck assembleDevDebug
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TachiyomiSY-${{ github.sha }}.apk
|
name: TachiyomiSY-${{ github.sha }}.apk
|
||||||
path: app/build/outputs/apk/dev/debug/app-dev-debug.apk
|
path: app/build/outputs/apk/dev/debug/app-dev-debug.apk
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "eu.kanade.tachiyomi.sy"
|
applicationId = "eu.kanade.tachiyomi.sy"
|
||||||
|
|
||||||
versionCode = 75
|
versionCode = 77
|
||||||
versionName = "1.12.0"
|
versionName = "1.12.0"
|
||||||
|
|
||||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||||
@@ -150,12 +150,14 @@ kotlin {
|
|||||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||||
|
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||||
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
|
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
|
||||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||||
|
"-Xannotation-default-target=param-property",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +192,7 @@ dependencies {
|
|||||||
implementation(androidx.paging.runtime)
|
implementation(androidx.paging.runtime)
|
||||||
implementation(androidx.paging.compose)
|
implementation(androidx.paging.compose)
|
||||||
|
|
||||||
implementation(libs.bundles.sqlite)
|
implementation(androidx.sqlite.bundled)
|
||||||
// SY -->
|
// SY -->
|
||||||
implementation(sylibs.sqlcipher)
|
implementation(sylibs.sqlcipher)
|
||||||
// SY <--
|
// SY <--
|
||||||
@@ -265,6 +267,7 @@ dependencies {
|
|||||||
implementation(libs.compose.grid)
|
implementation(libs.compose.grid)
|
||||||
implementation(libs.reorderable)
|
implementation(libs.reorderable)
|
||||||
implementation(libs.bundles.markdown)
|
implementation(libs.bundles.markdown)
|
||||||
|
implementation(libs.materialKolor)
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
implementation(libs.logcat)
|
implementation(libs.logcat)
|
||||||
|
|||||||
Vendored
+2
@@ -299,3 +299,5 @@
|
|||||||
-dontwarn org.ietf.jgss.GSSManager
|
-dontwarn org.ietf.jgss.GSSManager
|
||||||
-dontwarn org.ietf.jgss.GSSName
|
-dontwarn org.ietf.jgss.GSSName
|
||||||
-dontwarn org.ietf.jgss.Oid
|
-dontwarn org.ietf.jgss.Oid
|
||||||
|
-dontwarn com.google.re2j.Matcher
|
||||||
|
-dontwarn com.google.re2j.Pattern
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import tachiyomi.domain.category.interactor.SetMangaCategories
|
|||||||
import tachiyomi.domain.category.interactor.SetSortModeForCategory
|
import tachiyomi.domain.category.interactor.SetSortModeForCategory
|
||||||
import tachiyomi.domain.category.interactor.UpdateCategory
|
import tachiyomi.domain.category.interactor.UpdateCategory
|
||||||
import tachiyomi.domain.category.repository.CategoryRepository
|
import tachiyomi.domain.category.repository.CategoryRepository
|
||||||
|
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
@@ -156,6 +157,7 @@ class DomainModule : InjektModule {
|
|||||||
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
|
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
|
||||||
addFactory { GetChapter(get()) }
|
addFactory { GetChapter(get()) }
|
||||||
addFactory { GetChaptersByMangaId(get()) }
|
addFactory { GetChaptersByMangaId(get()) }
|
||||||
|
addFactory { GetBookmarkedChaptersByMangaId(get(), get(), get()) }
|
||||||
addFactory { GetChapterByUrlAndMangaId(get()) }
|
addFactory { GetChapterByUrlAndMangaId(get()) }
|
||||||
addFactory { UpdateChapter(get()) }
|
addFactory { UpdateChapter(get()) }
|
||||||
addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
|
addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
|
||||||
|
|||||||
@@ -35,4 +35,6 @@ class BasePreferences(
|
|||||||
fun hardwareBitmapThreshold() = preferenceStore.getInt("pref_hardware_bitmap_threshold", GLUtil.SAFE_TEXTURE_LIMIT)
|
fun hardwareBitmapThreshold() = preferenceStore.getInt("pref_hardware_bitmap_threshold", GLUtil.SAFE_TEXTURE_LIMIT)
|
||||||
|
|
||||||
fun alwaysDecodeLongStripWithSSIV() = preferenceStore.getBoolean("pref_always_decode_long_strip_with_ssiv", false)
|
fun alwaysDecodeLongStripWithSSIV() = preferenceStore.getBoolean("pref_always_decode_long_strip_with_ssiv", false)
|
||||||
|
|
||||||
|
fun installationId() = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -30,8 +30,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import com.gowtham.ratingbar.RatingBar
|
import com.gowtham.ratingbar.ComposeStars
|
||||||
import com.gowtham.ratingbar.RatingBarConfig
|
import com.gowtham.ratingbar.RatingBarStyle
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
import eu.kanade.presentation.manga.components.MangaCover
|
import eu.kanade.presentation.manga.components.MangaCover
|
||||||
import exh.metadata.MetadataUtil
|
import exh.metadata.MetadataUtil
|
||||||
@@ -222,17 +222,18 @@ fun BrowseSourceEHentaiListItem(
|
|||||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
RatingBar(
|
ComposeStars(
|
||||||
value = rating,
|
value = rating,
|
||||||
onValueChange = {},
|
numOfStars = 5,
|
||||||
onRatingChanged = {},
|
size = 18.dp,
|
||||||
config = RatingBarConfig().apply {
|
spaceBetween = 2.dp,
|
||||||
isIndicator(true)
|
hideInactiveStars = false,
|
||||||
numStars(5)
|
style = RatingBarStyle.Fill(
|
||||||
size(18.dp)
|
activeColor = Color(0xFF005ED7),
|
||||||
activeColor(Color(0xFF005ED7))
|
inActiveColor = Color(0xE1E2ECFF),
|
||||||
inactiveColor(Color(0xE1E2ECFF))
|
),
|
||||||
},
|
painterEmpty = null,
|
||||||
|
painterFilled = null,
|
||||||
)
|
)
|
||||||
val color = genre?.first?.color
|
val color = genre?.first?.color
|
||||||
val res = genre?.second
|
val res = genre?.second
|
||||||
|
|||||||
+2
-2
@@ -3,12 +3,12 @@ package eu.kanade.presentation.browse.components
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.FilterList
|
import androidx.compose.material.icons.outlined.FilterList
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.i18n.sy.SYMR
|
import tachiyomi.i18n.sy.SYMR
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -17,7 +17,7 @@ fun BrowseSourceFloatingActionButton(
|
|||||||
onFabClick: () -> Unit,
|
onFabClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
SmallExtendedFloatingActionButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+2
-2
@@ -4,11 +4,11 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Add
|
import androidx.compose.material.icons.outlined.Add
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.util.shouldExpandFAB
|
import tachiyomi.presentation.core.util.shouldExpandFAB
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ fun CategoryFloatingActionButton(
|
|||||||
onCreate: () -> Unit,
|
onCreate: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
SmallExtendedFloatingActionButton(
|
||||||
text = { Text(text = stringResource(MR.strings.action_add)) },
|
text = { Text(text = stringResource(MR.strings.action_add)) },
|
||||||
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
|
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
|
||||||
onClick = onCreate,
|
onClick = onCreate,
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.PlainTooltip
|
import androidx.compose.material3.PlainTooltip
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults.rememberTooltipPositionProvider
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
@@ -195,7 +196,7 @@ fun AppBarActions(
|
|||||||
|
|
||||||
actions.filterIsInstance<AppBar.Action>().map {
|
actions.filterIsInstance<AppBar.Action>().map {
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
tooltip = {
|
tooltip = {
|
||||||
PlainTooltip {
|
PlainTooltip {
|
||||||
Text(it.title)
|
Text(it.title)
|
||||||
@@ -220,7 +221,7 @@ fun AppBarActions(
|
|||||||
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
|
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
|
||||||
if (overflowActions.isNotEmpty()) {
|
if (overflowActions.isNotEmpty()) {
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
tooltip = {
|
tooltip = {
|
||||||
PlainTooltip {
|
PlainTooltip {
|
||||||
Text(stringResource(MR.strings.action_menu_overflow_description))
|
Text(stringResource(MR.strings.action_menu_overflow_description))
|
||||||
@@ -349,7 +350,7 @@ fun SearchToolbar(
|
|||||||
// Don't show search action
|
// Don't show search action
|
||||||
} else if (searchQuery == null) {
|
} else if (searchQuery == null) {
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
tooltip = {
|
tooltip = {
|
||||||
PlainTooltip {
|
PlainTooltip {
|
||||||
Text(stringResource(MR.strings.action_search))
|
Text(stringResource(MR.strings.action_search))
|
||||||
@@ -369,7 +370,7 @@ fun SearchToolbar(
|
|||||||
}
|
}
|
||||||
} else if (searchQuery.isNotEmpty()) {
|
} else if (searchQuery.isNotEmpty()) {
|
||||||
TooltipBox(
|
TooltipBox(
|
||||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||||
tooltip = {
|
tooltip = {
|
||||||
PlainTooltip {
|
PlainTooltip {
|
||||||
Text(stringResource(MR.strings.action_reset))
|
Text(stringResource(MR.strings.action_reset))
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.presentation.components
|
package eu.kanade.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -14,11 +13,11 @@ import tachiyomi.presentation.core.i18n.stringResource
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadDropdownMenu(
|
fun DownloadDropdownMenu(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDownloadClicked: (DownloadAction) -> Unit,
|
onDownloadClicked: (DownloadAction) -> Unit,
|
||||||
offset: DpOffset? = null,
|
offset: DpOffset? = null,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
if (offset != null) {
|
if (offset != null) {
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
@@ -49,7 +48,7 @@ fun DownloadDropdownMenu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.DownloadDropdownMenuItems(
|
private fun DownloadDropdownMenuItems(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDownloadClicked: (DownloadAction) -> Unit,
|
onDownloadClicked: (DownloadAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -59,6 +58,7 @@ private fun ColumnScope.DownloadDropdownMenuItems(
|
|||||||
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
|
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
|
||||||
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
|
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
|
||||||
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
|
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
|
||||||
|
DownloadAction.BOOKMARKED_CHAPTERS to stringResource(MR.strings.download_bookmarked),
|
||||||
)
|
)
|
||||||
|
|
||||||
options.map { (downloadAction, string) ->
|
options.map { (downloadAction, string) ->
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package eu.kanade.presentation.manga
|
package eu.kanade.presentation.manga
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -27,9 +24,11 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.animateFloatingActionButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -101,7 +100,6 @@ import tachiyomi.domain.source.model.StubSource
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.TwoPanelBox
|
import tachiyomi.presentation.core.components.TwoPanelBox
|
||||||
import tachiyomi.presentation.core.components.VerticalFastScroller
|
import tachiyomi.presentation.core.components.VerticalFastScroller
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.components.material.PullRefresh
|
import tachiyomi.presentation.core.components.material.PullRefresh
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
@@ -167,7 +165,7 @@ fun MangaScreen(
|
|||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
|
|
||||||
// Chapter selection
|
// Chapter selection
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onAllChapterSelected: (Boolean) -> Unit,
|
onAllChapterSelected: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -331,7 +329,7 @@ private fun MangaScreenSmallImpl(
|
|||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
|
|
||||||
// Chapter selection
|
// Chapter selection
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onAllChapterSelected: (Boolean) -> Unit,
|
onAllChapterSelected: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -418,25 +416,23 @@ private fun MangaScreenSmallImpl(
|
|||||||
val isFABVisible = remember(chapters) {
|
val isFABVisible = remember(chapters) {
|
||||||
chapters.fastAny { !it.chapter.read } && !isAnySelected
|
chapters.fastAny { !it.chapter.read } && !isAnySelected
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
SmallExtendedFloatingActionButton(
|
||||||
visible = isFABVisible,
|
text = {
|
||||||
enter = fadeIn(),
|
val isReading = remember(state.chapters) {
|
||||||
exit = fadeOut(),
|
state.chapters.fastAny { it.chapter.read }
|
||||||
) {
|
}
|
||||||
ExtendedFloatingActionButton(
|
Text(
|
||||||
text = {
|
text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start),
|
||||||
val isReading = remember(state.chapters) {
|
)
|
||||||
state.chapters.fastAny { it.chapter.read }
|
},
|
||||||
}
|
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
|
||||||
Text(
|
onClick = onContinueReading,
|
||||||
text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start),
|
expanded = chapterListState.shouldExpandFAB(),
|
||||||
)
|
modifier = Modifier.animateFloatingActionButton(
|
||||||
},
|
visible = isFABVisible,
|
||||||
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
|
alignment = Alignment.BottomEnd,
|
||||||
onClick = onContinueReading,
|
),
|
||||||
expanded = chapterListState.shouldExpandFAB(),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
val topPadding = contentPadding.calculateTopPadding()
|
val topPadding = contentPadding.calculateTopPadding()
|
||||||
@@ -654,7 +650,7 @@ fun MangaScreenLargeImpl(
|
|||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
|
|
||||||
// Chapter selection
|
// Chapter selection
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onAllChapterSelected: (Boolean) -> Unit,
|
onAllChapterSelected: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -737,27 +733,25 @@ fun MangaScreenLargeImpl(
|
|||||||
val isFABVisible = remember(chapters) {
|
val isFABVisible = remember(chapters) {
|
||||||
chapters.fastAny { !it.chapter.read } && !isAnySelected
|
chapters.fastAny { !it.chapter.read } && !isAnySelected
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
SmallExtendedFloatingActionButton(
|
||||||
visible = isFABVisible,
|
text = {
|
||||||
enter = fadeIn(),
|
val isReading = remember(state.chapters) {
|
||||||
exit = fadeOut(),
|
state.chapters.fastAny { it.chapter.read }
|
||||||
) {
|
}
|
||||||
ExtendedFloatingActionButton(
|
Text(
|
||||||
text = {
|
text = stringResource(
|
||||||
val isReading = remember(state.chapters) {
|
if (isReading) MR.strings.action_resume else MR.strings.action_start,
|
||||||
state.chapters.fastAny { it.chapter.read }
|
),
|
||||||
}
|
)
|
||||||
Text(
|
},
|
||||||
text = stringResource(
|
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
|
||||||
if (isReading) MR.strings.action_resume else MR.strings.action_start,
|
onClick = onContinueReading,
|
||||||
),
|
expanded = chapterListState.shouldExpandFAB(),
|
||||||
)
|
modifier = Modifier.animateFloatingActionButton(
|
||||||
},
|
visible = isFABVisible,
|
||||||
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
|
alignment = Alignment.BottomEnd,
|
||||||
onClick = onContinueReading,
|
),
|
||||||
expanded = chapterListState.shouldExpandFAB(),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
PullRefresh(
|
PullRefresh(
|
||||||
@@ -953,7 +947,7 @@ private fun LazyListScope.sharedChapterItems(
|
|||||||
// SY <--
|
// SY <--
|
||||||
onChapterClicked: (Chapter) -> Unit,
|
onChapterClicked: (Chapter) -> Unit,
|
||||||
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
|
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
@@ -1020,14 +1014,14 @@ private fun LazyListScope.sharedChapterItems(
|
|||||||
chapterSwipeStartAction = chapterSwipeStartAction,
|
chapterSwipeStartAction = chapterSwipeStartAction,
|
||||||
chapterSwipeEndAction = chapterSwipeEndAction,
|
chapterSwipeEndAction = chapterSwipeEndAction,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onChapterSelected(item, !item.selected, true, true)
|
onChapterSelected(item, !item.selected, true)
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onChapterItemClick(
|
onChapterItemClick(
|
||||||
chapterItem = item,
|
chapterItem = item,
|
||||||
isAnyChapterSelected = isAnyChapterSelected,
|
isAnyChapterSelected = isAnyChapterSelected,
|
||||||
onToggleSelection = { onChapterSelected(item, !item.selected, true, false) },
|
onToggleSelection = { onChapterSelected(item, !item.selected, false) },
|
||||||
onChapterClicked = onChapterClicked,
|
onChapterClicked = onChapterClicked,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ enum class DownloadAction {
|
|||||||
NEXT_10_CHAPTERS,
|
NEXT_10_CHAPTERS,
|
||||||
NEXT_25_CHAPTERS,
|
NEXT_25_CHAPTERS,
|
||||||
UNREAD_CHAPTERS,
|
UNREAD_CHAPTERS,
|
||||||
|
BOOKMARKED_CHAPTERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EditCoverAction {
|
enum class EditCoverAction {
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ fun MangaBottomActionMenu(
|
|||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) }
|
val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) }
|
||||||
var resetJob: Job? = remember { null }
|
var resetJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
(0..<7).forEach { i -> confirm[i] = i == toConfirmIndex }
|
confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||||
resetJob?.cancel()
|
resetJob?.cancel()
|
||||||
resetJob = scope.launch {
|
resetJob = scope.launch {
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
@@ -260,10 +260,10 @@ fun LibraryBottomActionMenu(
|
|||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val confirm = remember { mutableStateListOf(false, false, false, false, false, false) }
|
val confirm = remember { mutableStateListOf(false, false, false, false, false, false) }
|
||||||
var resetJob: Job? = remember { null }
|
var resetJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
(0..5).forEach { i -> confirm[i] = i == toConfirmIndex }
|
confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||||
resetJob?.cancel()
|
resetJob?.cancel()
|
||||||
resetJob = scope.launch {
|
resetJob = scope.launch {
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
|
|||||||
@@ -605,44 +605,47 @@ private fun ColumnScope.MangaContentInfo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = markdownAnnotator(
|
@Composable
|
||||||
annotate = { content, child ->
|
private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = remember(loadImages, linkStyle) {
|
||||||
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) {
|
markdownAnnotator(
|
||||||
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK)
|
annotate = { content, child ->
|
||||||
|
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) {
|
||||||
|
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK)
|
||||||
|
|
||||||
val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
|
val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
|
||||||
?.getUnescapedTextInNode(content)
|
|
||||||
?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
|
|
||||||
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
|
|
||||||
?.getUnescapedTextInNode(content)
|
?.getUnescapedTextInNode(content)
|
||||||
?: return@markdownAnnotator false
|
?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
|
||||||
|
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
|
||||||
|
?.getUnescapedTextInNode(content)
|
||||||
|
?: return@markdownAnnotator false
|
||||||
|
|
||||||
val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE)
|
val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE)
|
||||||
?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT)
|
?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT)
|
||||||
val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT)
|
val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT)
|
||||||
?.getUnescapedTextInNode(content).orEmpty()
|
?.getUnescapedTextInNode(content).orEmpty()
|
||||||
|
|
||||||
withLink(LinkAnnotation.Url(url = url)) {
|
withLink(LinkAnnotation.Url(url = url)) {
|
||||||
pushStyle(linkStyle)
|
pushStyle(linkStyle)
|
||||||
appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG)
|
appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG)
|
||||||
append(altText)
|
append(altText)
|
||||||
pop()
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return@markdownAnnotator true
|
||||||
}
|
}
|
||||||
|
|
||||||
return@markdownAnnotator true
|
if (child.type in DISALLOWED_MARKDOWN_TYPES) {
|
||||||
}
|
append(content.substring(child.startOffset, child.endOffset))
|
||||||
|
return@markdownAnnotator true
|
||||||
|
}
|
||||||
|
|
||||||
if (child.type in DISALLOWED_MARKDOWN_TYPES) {
|
false
|
||||||
append(content.substring(child.startOffset, child.endOffset))
|
},
|
||||||
return@markdownAnnotator true
|
config = markdownAnnotatorConfig(
|
||||||
}
|
eolAsNewLine = true,
|
||||||
|
),
|
||||||
false
|
)
|
||||||
},
|
}
|
||||||
config = markdownAnnotatorConfig(
|
|
||||||
eolAsNewLine = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MangaSummary(
|
private fun MangaSummary(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ sealed class Preference {
|
|||||||
override val title: String,
|
override val title: String,
|
||||||
override val subtitle: CharSequence? = null,
|
override val subtitle: CharSequence? = null,
|
||||||
override val enabled: Boolean = true,
|
override val enabled: Boolean = true,
|
||||||
|
val widget: @Composable (() -> Unit)? = null,
|
||||||
val onClick: (() -> Unit)? = null,
|
val onClick: (() -> Unit)? = null,
|
||||||
) : PreferenceItem<String, Unit>() {
|
) : PreferenceItem<String, Unit>() {
|
||||||
override val icon: ImageVector? = null
|
override val icon: ImageVector? = null
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ internal fun PreferenceItem(
|
|||||||
title = item.title,
|
title = item.title,
|
||||||
subtitle = item.subtitle,
|
subtitle = item.subtitle,
|
||||||
icon = item.icon,
|
icon = item.icon,
|
||||||
|
widget = item.widget,
|
||||||
onPreferenceClick = item.onClick,
|
onPreferenceClick = item.onClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -223,6 +223,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
private fun getDataGroup(): Preference.PreferenceGroup {
|
private fun getDataGroup(): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
return Preference.PreferenceGroup(
|
return Preference.PreferenceGroup(
|
||||||
title = stringResource(MR.strings.label_data),
|
title = stringResource(MR.strings.label_data),
|
||||||
@@ -231,8 +232,10 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
title = stringResource(MR.strings.pref_invalidate_download_cache),
|
title = stringResource(MR.strings.pref_invalidate_download_cache),
|
||||||
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
|
subtitle = stringResource(MR.strings.pref_invalidate_download_cache_summary),
|
||||||
onClick = {
|
onClick = {
|
||||||
Injekt.get<DownloadCache>().invalidateCache()
|
scope.launch {
|
||||||
context.toast(MR.strings.download_cache_invalidated)
|
Injekt.get<DownloadCache>().invalidateCache()
|
||||||
|
context.toast(MR.strings.download_cache_invalidated)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
|||||||
@@ -303,7 +303,10 @@ object SettingsDataScreen : SearchableSettings {
|
|||||||
|
|
||||||
val chapterCache = remember { Injekt.get<ChapterCache>() }
|
val chapterCache = remember { Injekt.get<ChapterCache>() }
|
||||||
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
|
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
|
||||||
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize }
|
var cacheReadableSize by remember { mutableStateOf(context.stringResource(MR.strings.calculating)) }
|
||||||
|
LaunchedEffect(cacheReadableSizeSema) {
|
||||||
|
cacheReadableSize = chapterCache.getReadableSize()
|
||||||
|
}
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
val pagePreviewCache = remember { Injekt.get<PagePreviewCache>() }
|
val pagePreviewCache = remember { Injekt.get<PagePreviewCache>() }
|
||||||
|
|||||||
@@ -245,7 +245,6 @@ object AboutScreen : Screen() {
|
|||||||
is GetApplicationRelease.Result.OsTooOld -> {
|
is GetApplicationRelease.Result.OsTooOld -> {
|
||||||
context.toast(MR.strings.update_check_eol)
|
context.toast(MR.strings.update_check_eol)
|
||||||
}
|
}
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
context.toast(e.message)
|
context.toast(e.message)
|
||||||
|
|||||||
+5
@@ -2,13 +2,16 @@ package eu.kanade.presentation.more.settings.screen.about
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
|
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
@@ -27,7 +30,9 @@ class OpenSourceLicensesScreen : Screen() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
|
val libraries by produceLibraries(R.raw.aboutlibraries)
|
||||||
LibrariesContainer(
|
LibrariesContainer(
|
||||||
|
libraries = libraries,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
|
|||||||
+2
@@ -1,6 +1,7 @@
|
|||||||
package eu.kanade.presentation.more.settings.screen.browse.components
|
package eu.kanade.presentation.more.settings.screen.browse.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -61,6 +62,7 @@ fun ExtensionRepoCreateDialog(
|
|||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
|||||||
@@ -9,12 +9,18 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
@@ -45,10 +51,24 @@ private fun StorageInfo(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) }
|
var available by remember(file) { mutableStateOf(-1L) }
|
||||||
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
|
var total by remember(file) { mutableStateOf(-1L) }
|
||||||
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
|
|
||||||
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
|
LaunchedEffect(file) {
|
||||||
|
available = withContext(Dispatchers.IO) { DiskUtil.getAvailableStorageSpace(file) }
|
||||||
|
total = withContext(Dispatchers.IO) { DiskUtil.getTotalStorageSpace(file) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val availableText = if (available == -1L) {
|
||||||
|
stringResource(MR.strings.calculating)
|
||||||
|
} else {
|
||||||
|
Formatter.formatFileSize(context, available)
|
||||||
|
}
|
||||||
|
val totalText = if (total == -1L) {
|
||||||
|
stringResource(MR.strings.calculating)
|
||||||
|
} else {
|
||||||
|
Formatter.formatFileSize(context, total)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||||
@@ -58,13 +78,15 @@ private fun StorageInfo(
|
|||||||
style = MaterialTheme.typography.header,
|
style = MaterialTheme.typography.header,
|
||||||
)
|
)
|
||||||
|
|
||||||
LinearProgressIndicator(
|
if (total > 0) {
|
||||||
modifier = Modifier
|
LinearProgressIndicator(
|
||||||
.clip(MaterialTheme.shapes.small)
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.clip(MaterialTheme.shapes.small)
|
||||||
.height(12.dp),
|
.fillMaxWidth()
|
||||||
progress = { (1 - (available / total.toFloat())) },
|
.height(12.dp),
|
||||||
)
|
progress = { (1 - (available / total.toFloat())) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(MR.strings.available_disk_space_info, availableText, totalText),
|
text = stringResource(MR.strings.available_disk_space_info, availableText, totalText),
|
||||||
|
|||||||
+43
-1
@@ -1,24 +1,38 @@
|
|||||||
package eu.kanade.presentation.more.settings.screen.debug
|
package eu.kanade.presentation.more.settings.screen.debug
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Autorenew
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.profileinstaller.ProfileVerifier
|
import androidx.profileinstaller.ProfileVerifier
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.presentation.more.settings.Preference
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
import eu.kanade.presentation.more.settings.PreferenceScaffold
|
import eu.kanade.presentation.more.settings.PreferenceScaffold
|
||||||
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
|
import eu.kanade.presentation.more.settings.screen.about.AboutScreen
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||||
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
import kotlinx.collections.immutable.mutate
|
import kotlinx.collections.immutable.mutate
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.guava.await
|
import kotlinx.coroutines.guava.await
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import mihon.core.common.FeatureFlags
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.presentation.core.util.collectAsState
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
class DebugInfoScreen : Screen() {
|
class DebugInfoScreen : Screen() {
|
||||||
|
|
||||||
@@ -47,6 +61,12 @@ class DebugInfoScreen : Screen() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun getAppInfoGroup(): Preference.PreferenceGroup {
|
private fun getAppInfoGroup(): Preference.PreferenceGroup {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val installationIdPref = remember { Injekt.get<BasePreferences>().installationId() }
|
||||||
|
val installationId by installationIdPref.collectAsState()
|
||||||
|
|
||||||
return Preference.PreferenceGroup(
|
return Preference.PreferenceGroup(
|
||||||
title = "App info",
|
title = "App info",
|
||||||
preferenceItems = persistentListOf(
|
preferenceItems = persistentListOf(
|
||||||
@@ -58,6 +78,28 @@ class DebugInfoScreen : Screen() {
|
|||||||
title = "Build time",
|
title = "Build time",
|
||||||
subtitle = AboutScreen.getFormattedBuildTime(),
|
subtitle = AboutScreen.getFormattedBuildTime(),
|
||||||
),
|
),
|
||||||
|
Preference.PreferenceItem.TextPreference(
|
||||||
|
title = "Installation ID",
|
||||||
|
subtitle = installationId,
|
||||||
|
widget = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
installationIdPref.set(FeatureFlags.newInstallationId())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Autorenew,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
context.copyToClipboard(installationId, installationId)
|
||||||
|
},
|
||||||
|
),
|
||||||
getProfileVerifierPreference(),
|
getProfileVerifierPreference(),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = "WebView version",
|
title = "WebView version",
|
||||||
@@ -78,7 +120,7 @@ class DebugInfoScreen : Screen() {
|
|||||||
val status by produceState(initialValue = "-") {
|
val status by produceState(initialValue = "-") {
|
||||||
val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode
|
val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode
|
||||||
value = when (result) {
|
value = when (result) {
|
||||||
ProfileVerifier.CompilationStatus.RESULT_CODE_NO_PROFILE -> "No profile installed"
|
ProfileVerifier.CompilationStatus.RESULT_CODE_NO_PROFILE_INSTALLED -> "No profile installed"
|
||||||
ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled"
|
ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled"
|
||||||
ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING ->
|
ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING ->
|
||||||
"Compiled non-matching"
|
"Compiled non-matching"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package eu.kanade.presentation.theme
|
package eu.kanade.presentation.theme
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import eu.kanade.domain.ui.UiPreferences
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
import eu.kanade.domain.ui.model.AppTheme
|
import eu.kanade.domain.ui.model.AppTheme
|
||||||
@@ -53,26 +54,36 @@ private fun BaseTachiyomiTheme(
|
|||||||
isAmoled: Boolean,
|
isAmoled: Boolean,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
MaterialTheme(
|
val context = LocalContext.current
|
||||||
colorScheme = getThemeColorScheme(appTheme, isAmoled),
|
val isDark = isSystemInDarkTheme()
|
||||||
|
MaterialExpressiveTheme(
|
||||||
|
colorScheme = remember(appTheme, isDark, isAmoled) {
|
||||||
|
getThemeColorScheme(
|
||||||
|
context = context,
|
||||||
|
appTheme = appTheme,
|
||||||
|
isDark = isDark,
|
||||||
|
isAmoled = isAmoled,
|
||||||
|
)
|
||||||
|
},
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
@ReadOnlyComposable
|
|
||||||
private fun getThemeColorScheme(
|
private fun getThemeColorScheme(
|
||||||
|
context: Context,
|
||||||
appTheme: AppTheme,
|
appTheme: AppTheme,
|
||||||
|
isDark: Boolean,
|
||||||
isAmoled: Boolean,
|
isAmoled: Boolean,
|
||||||
): ColorScheme {
|
): ColorScheme {
|
||||||
val colorScheme = if (appTheme == AppTheme.MONET) {
|
val colorScheme = if (appTheme == AppTheme.MONET) {
|
||||||
MonetColorScheme(LocalContext.current)
|
MonetColorScheme(context)
|
||||||
} else {
|
} else {
|
||||||
colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme)
|
colorSchemes.getOrDefault(appTheme, TachiyomiColorScheme)
|
||||||
}
|
}
|
||||||
return colorScheme.getColorScheme(
|
return colorScheme.getColorScheme(
|
||||||
isSystemInDarkTheme(),
|
isDark = isDark,
|
||||||
isAmoled,
|
isAmoled = isAmoled,
|
||||||
|
overrideDarkSurfaceContainers = appTheme != AppTheme.MONET,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,25 @@ internal abstract class BaseColorScheme {
|
|||||||
private val surfaceContainerHigh = Color(0xFF131313)
|
private val surfaceContainerHigh = Color(0xFF131313)
|
||||||
private val surfaceContainerHighest = Color(0xFF1B1B1B)
|
private val surfaceContainerHighest = Color(0xFF1B1B1B)
|
||||||
|
|
||||||
fun getColorScheme(isDark: Boolean, isAmoled: Boolean): ColorScheme {
|
fun getColorScheme(
|
||||||
|
isDark: Boolean,
|
||||||
|
isAmoled: Boolean,
|
||||||
|
overrideDarkSurfaceContainers: Boolean,
|
||||||
|
): ColorScheme {
|
||||||
if (!isDark) return lightScheme
|
if (!isDark) return lightScheme
|
||||||
|
|
||||||
if (!isAmoled) return darkScheme
|
if (!isAmoled) return darkScheme
|
||||||
|
|
||||||
return darkScheme.copy(
|
val amoledScheme = darkScheme.copy(
|
||||||
background = Color.Black,
|
background = Color.Black,
|
||||||
onBackground = Color.White,
|
onBackground = Color.White,
|
||||||
surface = Color.Black,
|
surface = Color.Black,
|
||||||
onSurface = Color.White,
|
onSurface = Color.White,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!overrideDarkSurfaceContainers) return amoledScheme
|
||||||
|
|
||||||
|
return amoledScheme.copy(
|
||||||
surfaceVariant = surfaceContainer, // Navigation bar background (ThemePrefWidget)
|
surfaceVariant = surfaceContainer, // Navigation bar background (ThemePrefWidget)
|
||||||
surfaceContainerLowest = surfaceContainer,
|
surfaceContainerLowest = surfaceContainer,
|
||||||
surfaceContainerLow = surfaceContainer,
|
surfaceContainerLow = surfaceContainer,
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
package eu.kanade.presentation.theme.colorscheme
|
package eu.kanade.presentation.theme.colorscheme
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.UiModeManager
|
|
||||||
import android.app.WallpaperManager
|
import android.app.WallpaperManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.core.content.getSystemService
|
import com.materialkolor.PaletteStyle
|
||||||
import com.google.android.material.color.utilities.Hct
|
import com.materialkolor.dynamiccolor.ColorSpec
|
||||||
import com.google.android.material.color.utilities.MaterialDynamicColors
|
import com.materialkolor.ktx.DynamicScheme
|
||||||
import com.google.android.material.color.utilities.QuantizerCelebi
|
import com.materialkolor.toColorScheme
|
||||||
import com.google.android.material.color.utilities.SchemeContent
|
|
||||||
import com.google.android.material.color.utilities.Score
|
|
||||||
|
|
||||||
internal class MonetColorScheme(context: Context) : BaseColorScheme() {
|
internal class MonetColorScheme(context: Context) : BaseColorScheme() {
|
||||||
|
|
||||||
@@ -28,7 +23,7 @@ internal class MonetColorScheme(context: Context) : BaseColorScheme() {
|
|||||||
?.primaryColor
|
?.primaryColor
|
||||||
?.toArgb()
|
?.toArgb()
|
||||||
if (seed != null) {
|
if (seed != null) {
|
||||||
MonetCompatColorScheme(context, seed)
|
MonetCompatColorScheme(Color(seed))
|
||||||
} else {
|
} else {
|
||||||
TachiyomiColorScheme
|
TachiyomiColorScheme
|
||||||
}
|
}
|
||||||
@@ -41,19 +36,6 @@ internal class MonetColorScheme(context: Context) : BaseColorScheme() {
|
|||||||
|
|
||||||
override val lightScheme
|
override val lightScheme
|
||||||
get() = monet.lightScheme
|
get() = monet.lightScheme
|
||||||
|
|
||||||
companion object {
|
|
||||||
@Suppress("Unused")
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
fun extractSeedColorFromImage(bitmap: Bitmap): Int? {
|
|
||||||
val width = bitmap.width
|
|
||||||
val height = bitmap.height
|
|
||||||
val bitmapPixels = IntArray(width * height)
|
|
||||||
bitmap.getPixels(bitmapPixels, 0, width, 0, 0, width, height)
|
|
||||||
return Score.score(QuantizerCelebi.quantize(bitmapPixels, 128), 1, 0)[0]
|
|
||||||
.takeIf { it != 0 } // Don't take fallback color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.S)
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
@@ -62,64 +44,19 @@ private class MonetSystemColorScheme(context: Context) : BaseColorScheme() {
|
|||||||
override val darkScheme = dynamicDarkColorScheme(context)
|
override val darkScheme = dynamicDarkColorScheme(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MonetCompatColorScheme(context: Context, seed: Int) : BaseColorScheme() {
|
internal class MonetCompatColorScheme(seed: Color) : BaseColorScheme() {
|
||||||
|
override val lightScheme = generateColorSchemeFromSeed(seed = seed, dark = false)
|
||||||
override val lightScheme = generateColorSchemeFromSeed(context = context, seed = seed, dark = false)
|
override val darkScheme = generateColorSchemeFromSeed(seed = seed, dark = true)
|
||||||
override val darkScheme = generateColorSchemeFromSeed(context = context, seed = seed, dark = true)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private fun Int.toComposeColor(): Color = Color(this)
|
fun generateColorSchemeFromSeed(seed: Color, dark: Boolean): ColorScheme {
|
||||||
|
return DynamicScheme(
|
||||||
@SuppressLint("PrivateResource", "RestrictedApi")
|
seedColor = seed,
|
||||||
private fun generateColorSchemeFromSeed(context: Context, seed: Int, dark: Boolean): ColorScheme {
|
isDark = dark,
|
||||||
val scheme = SchemeContent(
|
specVersion = ColorSpec.SpecVersion.SPEC_2025,
|
||||||
Hct.fromInt(seed),
|
style = PaletteStyle.Expressive,
|
||||||
dark,
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
context.getSystemService<UiModeManager>()?.contrast?.toDouble() ?: 0.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val dynamicColors = MaterialDynamicColors()
|
|
||||||
return ColorScheme(
|
|
||||||
primary = dynamicColors.primary().getArgb(scheme).toComposeColor(),
|
|
||||||
onPrimary = dynamicColors.onPrimary().getArgb(scheme).toComposeColor(),
|
|
||||||
primaryContainer = dynamicColors.primaryContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
onPrimaryContainer = dynamicColors.onPrimaryContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
inversePrimary = dynamicColors.inversePrimary().getArgb(scheme).toComposeColor(),
|
|
||||||
secondary = dynamicColors.secondary().getArgb(scheme).toComposeColor(),
|
|
||||||
onSecondary = dynamicColors.onSecondary().getArgb(scheme).toComposeColor(),
|
|
||||||
secondaryContainer = dynamicColors.secondaryContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
onSecondaryContainer = dynamicColors.onSecondaryContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
tertiary = dynamicColors.tertiary().getArgb(scheme).toComposeColor(),
|
|
||||||
onTertiary = dynamicColors.onTertiary().getArgb(scheme).toComposeColor(),
|
|
||||||
tertiaryContainer = dynamicColors.tertiary().getArgb(scheme).toComposeColor(),
|
|
||||||
onTertiaryContainer = dynamicColors.onTertiaryContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
background = dynamicColors.background().getArgb(scheme).toComposeColor(),
|
|
||||||
onBackground = dynamicColors.onBackground().getArgb(scheme).toComposeColor(),
|
|
||||||
surface = dynamicColors.surface().getArgb(scheme).toComposeColor(),
|
|
||||||
onSurface = dynamicColors.onSurface().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceVariant = dynamicColors.surfaceVariant().getArgb(scheme).toComposeColor(),
|
|
||||||
onSurfaceVariant = dynamicColors.onSurfaceVariant().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceTint = dynamicColors.surfaceTint().getArgb(scheme).toComposeColor(),
|
|
||||||
inverseSurface = dynamicColors.inverseSurface().getArgb(scheme).toComposeColor(),
|
|
||||||
inverseOnSurface = dynamicColors.inverseOnSurface().getArgb(scheme).toComposeColor(),
|
|
||||||
error = dynamicColors.error().getArgb(scheme).toComposeColor(),
|
|
||||||
onError = dynamicColors.onError().getArgb(scheme).toComposeColor(),
|
|
||||||
errorContainer = dynamicColors.errorContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
onErrorContainer = dynamicColors.onErrorContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
outline = dynamicColors.outline().getArgb(scheme).toComposeColor(),
|
|
||||||
outlineVariant = dynamicColors.outlineVariant().getArgb(scheme).toComposeColor(),
|
|
||||||
scrim = Color.Black,
|
|
||||||
surfaceBright = dynamicColors.surfaceBright().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceDim = dynamicColors.surfaceDim().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceContainer = dynamicColors.surfaceContainer().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceContainerHigh = dynamicColors.surfaceContainerHigh().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceContainerHighest = dynamicColors.surfaceContainerHighest().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceContainerLow = dynamicColors.surfaceContainerLow().getArgb(scheme).toComposeColor(),
|
|
||||||
surfaceContainerLowest = dynamicColors.surfaceContainerLowest().getArgb(scheme).toComposeColor(),
|
|
||||||
)
|
)
|
||||||
|
.toColorScheme(isAmoled = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package eu.kanade.presentation.track
|
package eu.kanade.presentation.track
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -47,6 +48,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -55,11 +57,11 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
import androidx.compose.ui.platform.Clipboard
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.platform.toClipEntry
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
@@ -73,6 +75,7 @@ import eu.kanade.presentation.manga.components.MangaCover
|
|||||||
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
|
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
|
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
@@ -240,7 +243,7 @@ private fun SearchResultItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
val clipboard: Clipboard = LocalClipboard.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val type = trackSearch.publishing_type.toLowerCase(Locale.current).capitalize(Locale.current)
|
val type = trackSearch.publishing_type.toLowerCase(Locale.current).capitalize(Locale.current)
|
||||||
val status = trackSearch.publishing_status.toLowerCase(Locale.current).capitalize(Locale.current)
|
val status = trackSearch.publishing_status.toLowerCase(Locale.current).capitalize(Locale.current)
|
||||||
@@ -248,6 +251,7 @@ private fun SearchResultItem(
|
|||||||
val shape = RoundedCornerShape(16.dp)
|
val shape = RoundedCornerShape(16.dp)
|
||||||
val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
|
val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
|
||||||
var dropDownMenuExpanded by remember { mutableStateOf(false) }
|
var dropDownMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -295,7 +299,13 @@ private fun SearchResultItem(
|
|||||||
expanded = dropDownMenuExpanded,
|
expanded = dropDownMenuExpanded,
|
||||||
onCollapseMenu = { dropDownMenuExpanded = false },
|
onCollapseMenu = { dropDownMenuExpanded = false },
|
||||||
onCopyName = {
|
onCopyName = {
|
||||||
clipboardManager.setText(AnnotatedString(trackSearch.title))
|
scope.launch {
|
||||||
|
val clipEntry = ClipData.newPlainText(
|
||||||
|
trackSearch.title,
|
||||||
|
trackSearch.title,
|
||||||
|
).toClipEntry()
|
||||||
|
clipboard.setClipEntry(clipEntry)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onOpenInBrowser = {
|
onOpenInBrowser = {
|
||||||
val url = trackSearch.tracking_url
|
val url = trackSearch.tracking_url
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package eu.kanade.presentation.updates
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import eu.kanade.presentation.components.TabbedDialog
|
||||||
|
import eu.kanade.presentation.components.TabbedDialogPaddings
|
||||||
|
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsScreenModel
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.presentation.core.components.SettingsItemsPaddings
|
||||||
|
import tachiyomi.presentation.core.components.TriStateItem
|
||||||
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
import tachiyomi.presentation.core.util.collectAsState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdatesFilterDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
screenModel: UpdatesSettingsScreenModel,
|
||||||
|
) {
|
||||||
|
TabbedDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
tabTitles = persistentListOf(
|
||||||
|
stringResource(MR.strings.action_filter),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = TabbedDialogPaddings.Vertical)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
FilterSheet(screenModel = screenModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.FilterSheet(
|
||||||
|
screenModel: UpdatesSettingsScreenModel,
|
||||||
|
) {
|
||||||
|
val filterDownloaded by screenModel.updatesPreferences.filterDownloaded().collectAsState()
|
||||||
|
TriStateItem(
|
||||||
|
label = stringResource(MR.strings.label_downloaded),
|
||||||
|
state = filterDownloaded,
|
||||||
|
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterDownloaded) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val filterUnread by screenModel.updatesPreferences.filterUnread().collectAsState()
|
||||||
|
TriStateItem(
|
||||||
|
label = stringResource(MR.strings.action_filter_unread),
|
||||||
|
state = filterUnread,
|
||||||
|
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterUnread) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val filterStarted by screenModel.updatesPreferences.filterStarted().collectAsState()
|
||||||
|
TriStateItem(
|
||||||
|
label = stringResource(MR.strings.label_started),
|
||||||
|
state = filterStarted,
|
||||||
|
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterStarted) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val filterBookmarked by screenModel.updatesPreferences.filterBookmarked().collectAsState()
|
||||||
|
TriStateItem(
|
||||||
|
label = stringResource(MR.strings.action_filter_bookmarked),
|
||||||
|
state = filterBookmarked,
|
||||||
|
onClick = { screenModel.toggleFilter(UpdatesPreferences::filterBookmarked) },
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.small))
|
||||||
|
|
||||||
|
val filterExcludedScanlators by screenModel.updatesPreferences.filterExcludedScanlators().collectAsState()
|
||||||
|
|
||||||
|
fun toggleScanlatorFilter() = screenModel.updatesPreferences.filterExcludedScanlators().getAndSet { !it }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { toggleScanlatorFilter() }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = SettingsItemsPaddings.Horizontal),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(MR.strings.action_filter_excluded_scanlators),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = filterExcludedScanlators,
|
||||||
|
onCheckedChange = { toggleScanlatorFilter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.FilterList
|
||||||
import androidx.compose.material.icons.outlined.FlipToBack
|
import androidx.compose.material.icons.outlined.FlipToBack
|
||||||
import androidx.compose.material.icons.outlined.Refresh
|
import androidx.compose.material.icons.outlined.Refresh
|
||||||
import androidx.compose.material.icons.outlined.SelectAll
|
import androidx.compose.material.icons.outlined.SelectAll
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
@@ -37,6 +40,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
|
|||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
import tachiyomi.presentation.core.theme.active
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -57,8 +61,10 @@ fun UpdateScreen(
|
|||||||
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
|
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
|
||||||
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
|
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
|
||||||
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
|
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
|
||||||
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
|
onUpdateSelected: (UpdatesItem, Boolean, Boolean) -> Unit,
|
||||||
onOpenChapter: (UpdatesItem) -> Unit,
|
onOpenChapter: (UpdatesItem) -> Unit,
|
||||||
|
onFilterClicked: () -> Unit,
|
||||||
|
hasActiveFilters: Boolean,
|
||||||
) {
|
) {
|
||||||
BackHandler(enabled = state.selectionMode) {
|
BackHandler(enabled = state.selectionMode) {
|
||||||
onSelectAll(false)
|
onSelectAll(false)
|
||||||
@@ -69,6 +75,8 @@ fun UpdateScreen(
|
|||||||
UpdatesAppBar(
|
UpdatesAppBar(
|
||||||
onCalendarClicked = { onCalendarClicked() },
|
onCalendarClicked = { onCalendarClicked() },
|
||||||
onUpdateLibrary = { onUpdateLibrary() },
|
onUpdateLibrary = { onUpdateLibrary() },
|
||||||
|
onFilterClicked = { onFilterClicked() },
|
||||||
|
hasFilters = hasActiveFilters,
|
||||||
actionModeCounter = state.selected.size,
|
actionModeCounter = state.selected.size,
|
||||||
onSelectAll = { onSelectAll(true) },
|
onSelectAll = { onSelectAll(true) },
|
||||||
onInvertSelection = { onInvertSelection() },
|
onInvertSelection = { onInvertSelection() },
|
||||||
@@ -139,6 +147,8 @@ fun UpdateScreen(
|
|||||||
private fun UpdatesAppBar(
|
private fun UpdatesAppBar(
|
||||||
onCalendarClicked: () -> Unit,
|
onCalendarClicked: () -> Unit,
|
||||||
onUpdateLibrary: () -> Unit,
|
onUpdateLibrary: () -> Unit,
|
||||||
|
onFilterClicked: () -> Unit,
|
||||||
|
hasFilters: Boolean,
|
||||||
// For action mode
|
// For action mode
|
||||||
actionModeCounter: Int,
|
actionModeCounter: Int,
|
||||||
onSelectAll: () -> Unit,
|
onSelectAll: () -> Unit,
|
||||||
@@ -153,6 +163,12 @@ private fun UpdatesAppBar(
|
|||||||
actions = {
|
actions = {
|
||||||
AppBarActions(
|
AppBarActions(
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(MR.strings.action_filter),
|
||||||
|
icon = Icons.Outlined.FilterList,
|
||||||
|
iconTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current,
|
||||||
|
onClick = onFilterClicked,
|
||||||
|
),
|
||||||
AppBar.Action(
|
AppBar.Action(
|
||||||
title = stringResource(MR.strings.action_view_upcoming),
|
title = stringResource(MR.strings.action_view_upcoming),
|
||||||
icon = Icons.Outlined.CalendarMonth,
|
icon = Icons.Outlined.CalendarMonth,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ internal fun LazyListScope.updatesUiItems(
|
|||||||
// SY -->
|
// SY -->
|
||||||
preserveReadingPosition: Boolean,
|
preserveReadingPosition: Boolean,
|
||||||
// SY <--
|
// SY <--
|
||||||
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
|
onUpdateSelected: (UpdatesItem, Boolean, Boolean) -> Unit,
|
||||||
onClickCover: (UpdatesItem) -> Unit,
|
onClickCover: (UpdatesItem) -> Unit,
|
||||||
onClickUpdate: (UpdatesItem) -> Unit,
|
onClickUpdate: (UpdatesItem) -> Unit,
|
||||||
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
|
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
|
||||||
@@ -120,11 +120,11 @@ internal fun LazyListScope.updatesUiItems(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onUpdateSelected(updatesItem, !updatesItem.selected, true, true)
|
onUpdateSelected(updatesItem, !updatesItem.selected, true)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
when {
|
when {
|
||||||
selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, true, false)
|
selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, false)
|
||||||
else -> onClickUpdate(updatesItem)
|
else -> onClickUpdate(updatesItem)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ import tachiyomi.domain.source.model.SourceNotInstalledException
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
context(Context)
|
context(context: Context)
|
||||||
val Throwable.formattedMessage: String
|
val Throwable.formattedMessage: String
|
||||||
get() {
|
get() {
|
||||||
when (this) {
|
when (this) {
|
||||||
is HttpException -> return stringResource(MR.strings.exception_http, code)
|
is HttpException -> return context.stringResource(MR.strings.exception_http, code)
|
||||||
is UnknownHostException -> {
|
is UnknownHostException -> {
|
||||||
return if (!isOnline()) {
|
return if (!context.isOnline()) {
|
||||||
stringResource(MR.strings.exception_offline)
|
context.stringResource(MR.strings.exception_offline)
|
||||||
} else {
|
} else {
|
||||||
stringResource(MR.strings.exception_unknown_host, message ?: "")
|
context.stringResource(MR.strings.exception_unknown_host, message ?: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NoResultsException -> return stringResource(MR.strings.no_results_found)
|
is NoResultsException -> return context.stringResource(MR.strings.no_results_found)
|
||||||
is SourceNotInstalledException -> return stringResource(MR.strings.loader_not_implemented_error)
|
is SourceNotInstalledException -> return context.stringResource(MR.strings.loader_not_implemented_error)
|
||||||
}
|
}
|
||||||
return when (val className = this::class.simpleName) {
|
return when (val className = this::class.simpleName) {
|
||||||
"Exception", "IOException" -> message ?: className
|
"Exception", "IOException" -> message ?: className
|
||||||
|
|||||||
@@ -4,5 +4,7 @@ import androidx.compose.foundation.lazy.LazyItemScope
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
// https://issuetracker.google.com/352584409
|
// https://issuetracker.google.com/352584409
|
||||||
context(LazyItemScope)
|
context(itemScope: LazyItemScope)
|
||||||
fun Modifier.animateItemFastScroll() = this.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
fun Modifier.animateItemFastScroll() = with(itemScope) {
|
||||||
|
this@animateItemFastScroll.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
|
|||||||
)
|
)
|
||||||
is LoadingState.Loading -> {
|
is LoadingState.Loading -> {
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
(loadingState as? LoadingState.Loading)?.progress ?: 1f,
|
loadingState.progress,
|
||||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||||
label = "webview_loading",
|
label = "webview_loading",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ fun WebViewScreenContent(
|
|||||||
.align(Alignment.BottomCenter),
|
.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
is LoadingState.Loading -> LinearProgressIndicator(
|
is LoadingState.Loading -> LinearProgressIndicator(
|
||||||
progress = { (loadingState as? LoadingState.Loading)?.progress ?: 1f },
|
progress = { loadingState.progress },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter),
|
.align(Alignment.BottomCenter),
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ class BackupCategory(
|
|||||||
@ProtoNumber(100) var flags: Long = 0,
|
@ProtoNumber(100) var flags: Long = 0,
|
||||||
// SY specific values
|
// SY specific values
|
||||||
/*@ProtoNumber(600) var mangaOrder: List<Long> = emptyList(),*/
|
/*@ProtoNumber(600) var mangaOrder: List<Long> = emptyList(),*/
|
||||||
|
@ProtoNumber(601) var version: Long = 0,
|
||||||
|
@ProtoNumber(602) var uid: Long = 0,
|
||||||
|
@ProtoNumber(603) var lastModifiedAt: Long = 0,
|
||||||
) {
|
) {
|
||||||
fun toCategory(id: Long) = Category(
|
fun toCategory(id: Long) = Category(
|
||||||
id = id,
|
id = id,
|
||||||
name = this@BackupCategory.name,
|
name = this@BackupCategory.name,
|
||||||
flags = this@BackupCategory.flags,
|
flags = this@BackupCategory.flags,
|
||||||
order = this@BackupCategory.order,
|
order = this@BackupCategory.order,
|
||||||
|
version = this@BackupCategory.version,
|
||||||
|
uid = this@BackupCategory.uid,
|
||||||
|
lastModifiedAt = this@BackupCategory.lastModifiedAt,
|
||||||
/*mangaOrder = this@BackupCategory.mangaOrder*/
|
/*mangaOrder = this@BackupCategory.mangaOrder*/
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -29,5 +35,8 @@ val backupCategoryMapper = { category: Category ->
|
|||||||
name = category.name,
|
name = category.name,
|
||||||
order = category.order,
|
order = category.order,
|
||||||
flags = category.flags,
|
flags = category.flags,
|
||||||
|
version = category.version,
|
||||||
|
uid = category.uid,
|
||||||
|
lastModifiedAt = category.lastModifiedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-5
@@ -17,20 +17,63 @@ class CategoriesRestorer(
|
|||||||
if (backupCategories.isNotEmpty()) {
|
if (backupCategories.isNotEmpty()) {
|
||||||
val dbCategories = getCategories.await()
|
val dbCategories = getCategories.await()
|
||||||
val dbCategoriesByName = dbCategories.associateBy { it.name }
|
val dbCategoriesByName = dbCategories.associateBy { it.name }
|
||||||
|
// SY -->
|
||||||
|
val dbCategoriesByUid = dbCategories.associateBy { it.uid } // Map by UID
|
||||||
|
// SY <--
|
||||||
|
|
||||||
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
|
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
|
||||||
|
|
||||||
val categories = backupCategories
|
val categories = backupCategories
|
||||||
.sortedBy { it.order }
|
.sortedBy { it.order }
|
||||||
.map {
|
// SY -->
|
||||||
val dbCategory = dbCategoriesByName[it.name]
|
.map { backupCategory ->
|
||||||
if (dbCategory != null) return@map dbCategory
|
var dbCategory = if (backupCategory.uid != 0L) {
|
||||||
|
dbCategoriesByUid[backupCategory.uid]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbCategory == null) {
|
||||||
|
dbCategory = dbCategoriesByName[backupCategory.name]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbCategory != null) {
|
||||||
|
handler.await {
|
||||||
|
categoriesQueries.update(
|
||||||
|
name = backupCategory.name,
|
||||||
|
order = backupCategory.order,
|
||||||
|
flags = backupCategory.flags,
|
||||||
|
version = backupCategory.version,
|
||||||
|
uid = if (backupCategory.uid != 0L) backupCategory.uid else dbCategory.uid,
|
||||||
|
last_modified_at = backupCategory.lastModifiedAt,
|
||||||
|
isSyncing = 1,
|
||||||
|
categoryId = dbCategory.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@map dbCategory
|
||||||
|
}
|
||||||
|
|
||||||
val order = nextOrder++
|
val order = nextOrder++
|
||||||
handler.awaitOneExecutable {
|
handler.awaitOneExecutable {
|
||||||
categoriesQueries.insert(it.name, order, it.flags)
|
categoriesQueries.insert(
|
||||||
|
backupCategory.name,
|
||||||
|
order,
|
||||||
|
backupCategory.flags,
|
||||||
|
backupCategory.version,
|
||||||
|
backupCategory.uid,
|
||||||
|
backupCategory.lastModifiedAt,
|
||||||
|
)
|
||||||
categoriesQueries.selectLastInsertedRowId()
|
categoriesQueries.selectLastInsertedRowId()
|
||||||
}
|
}
|
||||||
.let { id -> it.toCategory(id).copy(order = order) }
|
.let { id -> backupCategory.toCategory(id).copy(order = order) }
|
||||||
}
|
}
|
||||||
|
// SY <--
|
||||||
|
|
||||||
|
// SY -->
|
||||||
|
handler.await {
|
||||||
|
categoriesQueries.resetIsSyncing()
|
||||||
|
}
|
||||||
|
// SY <--
|
||||||
|
|
||||||
libraryPreferences.categorizedDisplaySettings().set(
|
libraryPreferences.categorizedDisplaySettings().set(
|
||||||
(dbCategories + categories)
|
(dbCategories + categories)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -63,17 +64,13 @@ class ChapterCache(
|
|||||||
*/
|
*/
|
||||||
private val cacheDir: File = diskCache.directory
|
private val cacheDir: File = diskCache.directory
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns real size of directory.
|
|
||||||
*/
|
|
||||||
private val realSize: Long
|
|
||||||
get() = DiskUtil.getDirectorySize(cacheDir)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns real size of directory in human readable format.
|
* Returns real size of directory in human readable format.
|
||||||
*/
|
*/
|
||||||
val readableSize: String
|
suspend fun getReadableSize(): String = withContext(Dispatchers.IO) {
|
||||||
get() = Formatter.formatFileSize(context, realSize)
|
val size = DiskUtil.getDirectorySize(cacheDir)
|
||||||
|
Formatter.formatFileSize(context, size)
|
||||||
|
}
|
||||||
|
|
||||||
// --> EH
|
// --> EH
|
||||||
// Cache size is in MB
|
// Cache size is in MB
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -109,13 +112,19 @@ class DownloadCache(
|
|||||||
ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes())
|
ProtoBuf.decodeFromByteArray<RootDirectory>(it.readBytes())
|
||||||
}
|
}
|
||||||
rootDownloadsDir = diskCache
|
rootDownloadsDir = diskCache
|
||||||
lastRenew = System.currentTimeMillis()
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to initialize from disk cache" }
|
logcat(LogPriority.ERROR, e) { "Failed to initialize from disk cache" }
|
||||||
diskCacheFile.delete()
|
diskCacheFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceManager.catalogueSources
|
||||||
|
.map { sources -> sources.map { it.id }.toSet() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
restartRenewal()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
storageManager.changes
|
storageManager.changes
|
||||||
@@ -353,19 +362,34 @@ class DownloadCache(
|
|||||||
notifyChanges()
|
notifyChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateCache() {
|
suspend fun invalidateCache() {
|
||||||
lastRenew = 0L
|
renewalJob?.cancelAndJoin()
|
||||||
renewalJob?.cancel()
|
|
||||||
diskCacheFile.delete()
|
diskCacheFile.delete()
|
||||||
renewCache()
|
lastRenew = 0L
|
||||||
|
renewCache(forceRenew = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely cancels any in-progress renewal job, resets the last-renew timestamp, and
|
||||||
|
* immediately starts a new renewal, bypassing the time-based throttle.
|
||||||
|
*/
|
||||||
|
private fun restartRenewal() {
|
||||||
|
renewalJob?.cancel()
|
||||||
|
lastRenew = 0L
|
||||||
|
renewCache(forceRenew = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renews the downloads cache.
|
* Renews the downloads cache.
|
||||||
|
*
|
||||||
|
* @param forceRenew when `true`, the time-based throttle is bypassed. Use this after
|
||||||
|
* explicitly cancelling the previous job to avoid a race where the cancelled job's
|
||||||
|
* [invokeOnCompletion] handler sets [lastRenew] after the reset but before the new
|
||||||
|
* job's guard check.
|
||||||
*/
|
*/
|
||||||
private fun renewCache() {
|
private fun renewCache(forceRenew: Boolean = false) {
|
||||||
// Avoid renewing cache if in the process nor too often
|
// Avoid renewing cache if in the process nor too often
|
||||||
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
|
if ((!forceRenew && lastRenew + renewInterval >= System.currentTimeMillis()) || renewalJob?.isActive == true) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,15 +400,14 @@ class DownloadCache(
|
|||||||
|
|
||||||
// Try to wait until extensions and sources have loaded
|
// Try to wait until extensions and sources have loaded
|
||||||
// SY -->
|
// SY -->
|
||||||
var sources = emptyList<Source>()
|
|
||||||
withTimeoutOrNull(30.seconds) {
|
withTimeoutOrNull(30.seconds) {
|
||||||
extensionManager.isInitialized.first { it }
|
// SY <--
|
||||||
sourceManager.isInitialized.first { it }
|
sourceManager.catalogueSources.first { it.isNotEmpty() }
|
||||||
|
// SY -->
|
||||||
sources = getSources()
|
|
||||||
}
|
}
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
|
val sources = getSources()
|
||||||
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id }
|
||||||
|
|
||||||
rootDownloadsDirMutex.withLock {
|
rootDownloadsDirMutex.withLock {
|
||||||
@@ -459,8 +482,9 @@ class DownloadCache(
|
|||||||
|
|
||||||
private var updateDiskCacheJob: Job? = null
|
private var updateDiskCacheJob: Job? = null
|
||||||
private fun updateDiskCache() {
|
private fun updateDiskCache() {
|
||||||
updateDiskCacheJob?.cancel()
|
val previousJob = updateDiskCacheJob
|
||||||
updateDiskCacheJob = scope.launchIO {
|
updateDiskCacheJob = scope.launchIO {
|
||||||
|
previousJob?.cancelAndJoin()
|
||||||
delay(1000)
|
delay(1000)
|
||||||
ensureActive()
|
ensureActive()
|
||||||
val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir)
|
val bytes = ProtoBuf.encodeToByteArray(rootDownloadsDir)
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ class DownloadManager(
|
|||||||
return queueState.value.find { it.chapter.id == chapterId }
|
return queueState.value.find { it.chapter.id == chapterId }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startDownloadNow(chapterId: Long) {
|
suspend fun startDownloadNow(chapterId: Long) {
|
||||||
val existingDownload = getQueuedDownloadOrNull(chapterId)
|
val existingDownload = getQueuedDownloadOrNull(chapterId)
|
||||||
// If not in queue try to start a new download
|
// If not in queue try to start a new download
|
||||||
val toAdd = existingDownload ?: runBlocking { Download.fromChapterId(chapterId) } ?: return
|
val toAdd = existingDownload ?: Download.fromChapterId(chapterId) ?: return
|
||||||
queueState.value.toMutableList().apply {
|
queueState.value.toMutableList().apply {
|
||||||
existingDownload?.let { remove(it) }
|
existingDownload?.let { remove(it) }
|
||||||
add(0, toAdd)
|
add(0, toAdd)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class DownloadStore(
|
|||||||
/**
|
/**
|
||||||
* Returns the list of downloads to restore. It should be called in a background thread.
|
* Returns the list of downloads to restore. It should be called in a background thread.
|
||||||
*/
|
*/
|
||||||
fun restore(): List<Download> {
|
suspend fun restore(): List<Download> {
|
||||||
val objs = preferences.all
|
val objs = preferences.all
|
||||||
.mapNotNull { it.value as? String }
|
.mapNotNull { it.value as? String }
|
||||||
.mapNotNull { deserialize(it) }
|
.mapNotNull { deserialize(it) }
|
||||||
@@ -100,10 +100,10 @@ class DownloadStore(
|
|||||||
val cachedManga = mutableMapOf<Long, Manga?>()
|
val cachedManga = mutableMapOf<Long, Manga?>()
|
||||||
for ((mangaId, chapterId) in objs) {
|
for ((mangaId, chapterId) in objs) {
|
||||||
val manga = cachedManga.getOrPut(mangaId) {
|
val manga = cachedManga.getOrPut(mangaId) {
|
||||||
runBlocking { getManga.await(mangaId) }
|
getManga.await(mangaId)
|
||||||
} ?: continue
|
} ?: continue
|
||||||
val source = sourceManager.get(manga.source) as? HttpSource ?: continue
|
val source = sourceManager.get(manga.source) as? HttpSource ?: continue
|
||||||
val chapter = runBlocking { getChapter.await(chapterId) } ?: continue
|
val chapter = getChapter.await(chapterId) ?: continue
|
||||||
downloads.add(Download(source, manga, chapter))
|
downloads.add(Download(source, manga, chapter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ class Downloader(
|
|||||||
var isPaused: Boolean = false
|
var isPaused: Boolean = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
launchNow {
|
scope.launch {
|
||||||
val chapters = async { store.restore() }
|
val chapters = store.restore()
|
||||||
addAllToQueue(chapters.await())
|
addAllToQueue(chapters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,7 +420,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
is SourceNotInstalledException -> context.stringResource(
|
is SourceNotInstalledException -> context.stringResource(
|
||||||
MR.strings.loader_not_implemented_error,
|
MR.strings.loader_not_implemented_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> e.message
|
else -> e.message
|
||||||
}
|
}
|
||||||
failedUpdates.add(manga to errorMessage)
|
failedUpdates.add(manga to errorMessage)
|
||||||
@@ -692,8 +691,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
}
|
}
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {}
|
||||||
}
|
|
||||||
return File("")
|
return File("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,10 +735,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
const val KEY_GROUP_EXTRA = "group_extra"
|
const val KEY_GROUP_EXTRA = "group_extra"
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
fun cancelAllWorks(context: Context) {
|
|
||||||
context.workManager.cancelAllWorkByTag(TAG)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setupTask(
|
fun setupTask(
|
||||||
context: Context,
|
context: Context,
|
||||||
prefInterval: Int? = null,
|
prefInterval: Int? = null,
|
||||||
@@ -754,16 +748,19 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
|||||||
} else {
|
} else {
|
||||||
NetworkType.CONNECTED
|
NetworkType.CONNECTED
|
||||||
}
|
}
|
||||||
val networkRequestBuilder = NetworkRequest.Builder()
|
val networkRequest = NetworkRequest.Builder().apply {
|
||||||
if (DEVICE_ONLY_ON_WIFI in restrictions) {
|
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
networkRequestBuilder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
if (DEVICE_ONLY_ON_WIFI in restrictions) {
|
||||||
}
|
addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
|
}
|
||||||
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
|
||||||
|
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.build()
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
|
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
|
||||||
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType)
|
.setRequiredNetworkRequest(networkRequest, networkType)
|
||||||
.setRequiresCharging(DEVICE_CHARGING in restrictions)
|
.setRequiresCharging(DEVICE_CHARGING in restrictions)
|
||||||
.setRequiresBatteryNotLow(true)
|
.setRequiresBatteryNotLow(true)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import tachiyomi.core.common.util.lang.launchIO
|
||||||
|
import tachiyomi.core.common.util.lang.withUIContext
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
@@ -84,11 +85,18 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
|
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
|
||||||
// Open reader activity
|
// Open reader activity
|
||||||
ACTION_OPEN_CHAPTER -> {
|
ACTION_OPEN_CHAPTER -> {
|
||||||
openChapter(
|
val pendingResult = goAsync()
|
||||||
context,
|
launchIO {
|
||||||
intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
try {
|
||||||
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
|
openChapter(
|
||||||
)
|
context,
|
||||||
|
intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
||||||
|
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Mark updated manga chapters as read
|
// Mark updated manga chapters as read
|
||||||
ACTION_MARK_AS_READ -> {
|
ACTION_MARK_AS_READ -> {
|
||||||
@@ -153,16 +161,18 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
* @param mangaId id of manga
|
* @param mangaId id of manga
|
||||||
* @param chapterId id of chapter
|
* @param chapterId id of chapter
|
||||||
*/
|
*/
|
||||||
private fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
|
private suspend fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
|
||||||
val manga = runBlocking { getManga.await(mangaId) }
|
val manga = getManga.await(mangaId)
|
||||||
val chapter = runBlocking { getChapter.await(chapterId) }
|
val chapter = getChapter.await(chapterId)
|
||||||
if (manga != null && chapter != null) {
|
withUIContext {
|
||||||
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
|
if (manga != null && chapter != null) {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} else {
|
||||||
|
context.toast(MR.strings.chapter_error)
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
|
||||||
} else {
|
|
||||||
context.toast(MR.strings.chapter_error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class SyncManager(
|
|||||||
handler.await(inTransaction = true) {
|
handler.await(inTransaction = true) {
|
||||||
mangasQueries.resetIsSyncing()
|
mangasQueries.resetIsSyncing()
|
||||||
chaptersQueries.resetIsSyncing()
|
chaptersQueries.resetIsSyncing()
|
||||||
|
categoriesQueries.resetIsSyncing()
|
||||||
}
|
}
|
||||||
|
|
||||||
val syncOptions = syncPreferences.getSyncSettings()
|
val syncOptions = syncPreferences.getSyncSettings()
|
||||||
@@ -156,7 +157,7 @@ class SyncManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop the sync early if the remote backup is null or empty
|
// Stop the sync early if the remote backup is null or empty
|
||||||
if (remoteBackup.backupManga.size == 0) {
|
if (remoteBackup.backupManga.isEmpty() && remoteBackup.backupCategories.isEmpty() && remoteBackup.backupSources.isEmpty()) {
|
||||||
notifier.showSyncError("No data found on remote server.")
|
notifier.showSyncError("No data found on remote server.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -185,14 +186,40 @@ class SyncManager(
|
|||||||
// SY <--
|
// SY <--
|
||||||
)
|
)
|
||||||
|
|
||||||
// It's local sync no need to restore data. (just update remote data)
|
val hasMangaChanges = filteredFavorites.isNotEmpty()
|
||||||
if (filteredFavorites.isEmpty()) {
|
val hasCategoryChanges = remoteBackup.backupCategories != backup.backupCategories
|
||||||
|
val hasSourceChanges = remoteBackup.backupSources != backup.backupSources
|
||||||
|
val hasPreferenceChanges = remoteBackup.backupPreferences != backup.backupPreferences
|
||||||
|
val hasSourcePreferenceChanges = remoteBackup.backupSourcePreferences != backup.backupSourcePreferences
|
||||||
|
val hasExtensionRepoChanges = remoteBackup.backupExtensionRepo != backup.backupExtensionRepo
|
||||||
|
val hasSavedSearchChanges = remoteBackup.backupSavedSearches != backup.backupSavedSearches
|
||||||
|
|
||||||
|
if (!hasMangaChanges && !hasCategoryChanges && !hasSourceChanges &&
|
||||||
|
!hasPreferenceChanges && !hasSourcePreferenceChanges &&
|
||||||
|
!hasExtensionRepoChanges && !hasSavedSearchChanges
|
||||||
|
) {
|
||||||
// update the sync timestamp
|
// update the sync timestamp
|
||||||
syncPreferences.lastSyncTimestamp().set(Date().time)
|
syncPreferences.lastSyncTimestamp().set(Date().time)
|
||||||
notifier.showSyncSuccess("Sync completed successfully")
|
notifier.showSyncSuccess("Sync completed successfully")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (syncOptions.categories) {
|
||||||
|
val mergedUids = newSyncData.backupCategories.map { it.uid }.toSet()
|
||||||
|
val mergedNames = newSyncData.backupCategories.map { it.name }.toSet()
|
||||||
|
val localCategories = getCategories.await().filterNot { it.id == 0L } // Exclude system category
|
||||||
|
val categoriesToDelete = localCategories.filter {
|
||||||
|
it.uid !in mergedUids && it.name !in mergedNames
|
||||||
|
}
|
||||||
|
if (categoriesToDelete.isNotEmpty()) {
|
||||||
|
handler.await(inTransaction = true) {
|
||||||
|
categoriesToDelete.forEach {
|
||||||
|
categoriesQueries.delete(it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val backupUri = writeSyncDataToCache(context, newSyncData)
|
val backupUri = writeSyncDataToCache(context, newSyncData)
|
||||||
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
|
logcat(LogPriority.DEBUG) { "Got Backup Uri: $backupUri" }
|
||||||
if (backupUri != null) {
|
if (backupUri != null) {
|
||||||
@@ -201,10 +228,14 @@ class SyncManager(
|
|||||||
backupUri,
|
backupUri,
|
||||||
sync = true,
|
sync = true,
|
||||||
options = RestoreOptions(
|
options = RestoreOptions(
|
||||||
appSettings = true,
|
appSettings = syncOptions.appSettings,
|
||||||
sourceSettings = true,
|
sourceSettings = syncOptions.sourceSettings,
|
||||||
libraryEntries = true,
|
libraryEntries = syncOptions.libraryEntries,
|
||||||
extensionRepoSettings = true,
|
categories = syncOptions.categories,
|
||||||
|
extensionRepoSettings = syncOptions.extensionRepoSettings,
|
||||||
|
// SY -->
|
||||||
|
savedSearches = syncOptions.savedSearches,
|
||||||
|
// SY <--
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import logcat.logcat
|
import logcat.logcat
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncData(
|
data class SyncData(
|
||||||
@@ -134,14 +136,31 @@ abstract class SyncService(
|
|||||||
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
|
"Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val lastSyncTime = syncPreferences.lastSyncTimestamp().get().milliseconds.inWholeSeconds
|
||||||
|
val syncOptions = syncPreferences.getSyncSettings()
|
||||||
|
|
||||||
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
|
val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
|
||||||
val local = localMangaMap[compositeKey]
|
val local = localMangaMap[compositeKey]
|
||||||
val remote = remoteMangaMap[compositeKey]
|
val remote = remoteMangaMap[compositeKey]
|
||||||
|
|
||||||
// New version comparison logic
|
// New version comparison logic
|
||||||
when {
|
when {
|
||||||
local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder)
|
local != null && remote == null -> {
|
||||||
local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder)
|
if (lastSyncTime == 0L || local.lastModifiedAt > lastSyncTime) {
|
||||||
|
updateCategories(local, localCategoriesMapByOrder)
|
||||||
|
} else {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Dropping local manga deleted on remote: ${local.title}." }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local == null && remote != null -> {
|
||||||
|
if (lastSyncTime == 0L || remote.lastModifiedAt > lastSyncTime) {
|
||||||
|
updateCategories(remote, remoteCategoriesMapByOrder)
|
||||||
|
} else {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote manga: ${remote.title}." }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
local != null && remote != null -> {
|
local != null && remote != null -> {
|
||||||
// Compare versions to decide which manga to keep
|
// Compare versions to decide which manga to keep
|
||||||
if (local.version >= remote.version) {
|
if (local.version >= remote.version) {
|
||||||
@@ -149,7 +168,7 @@ abstract class SyncService(
|
|||||||
"Keeping local version of ${local.title} with merged chapters."
|
"Keeping local version of ${local.title} with merged chapters."
|
||||||
}
|
}
|
||||||
updateCategories(
|
updateCategories(
|
||||||
local.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
|
local.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
|
||||||
localCategoriesMapByOrder,
|
localCategoriesMapByOrder,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -157,7 +176,7 @@ abstract class SyncService(
|
|||||||
"Keeping remote version of ${remote.title} with merged chapters."
|
"Keeping remote version of ${remote.title} with merged chapters."
|
||||||
}
|
}
|
||||||
updateCategories(
|
updateCategories(
|
||||||
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)),
|
remote.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)),
|
||||||
remoteCategoriesMapByOrder,
|
remoteCategoriesMapByOrder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,9 +216,15 @@ abstract class SyncService(
|
|||||||
private fun mergeChapters(
|
private fun mergeChapters(
|
||||||
localChapters: List<BackupChapter>,
|
localChapters: List<BackupChapter>,
|
||||||
remoteChapters: List<BackupChapter>,
|
remoteChapters: List<BackupChapter>,
|
||||||
|
lastSyncTime: Long,
|
||||||
|
syncingChapters: Boolean,
|
||||||
): List<BackupChapter> {
|
): List<BackupChapter> {
|
||||||
val logTag = "MergeChapters"
|
val logTag = "MergeChapters"
|
||||||
|
|
||||||
|
if (!syncingChapters) {
|
||||||
|
return remoteChapters // If not syncing chapters, keep remote untouched
|
||||||
|
}
|
||||||
|
|
||||||
fun chapterCompositeKey(chapter: BackupChapter): String {
|
fun chapterCompositeKey(chapter: BackupChapter): String {
|
||||||
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
|
return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
|
||||||
}
|
}
|
||||||
@@ -223,12 +248,22 @@ abstract class SyncService(
|
|||||||
|
|
||||||
when {
|
when {
|
||||||
localChapter != null && remoteChapter == null -> {
|
localChapter != null && remoteChapter == null -> {
|
||||||
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
|
if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) {
|
||||||
localChapter
|
logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." }
|
||||||
|
localChapter
|
||||||
|
} else {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Dropping local chapter deleted on remote: ${localChapter.name}." }
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
localChapter == null && remoteChapter != null -> {
|
localChapter == null && remoteChapter != null -> {
|
||||||
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
|
if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) {
|
||||||
remoteChapter
|
logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." }
|
||||||
|
remoteChapter
|
||||||
|
} else {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote chapter: ${remoteChapter.name}." }
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
localChapter != null && remoteChapter != null -> {
|
localChapter != null && remoteChapter != null -> {
|
||||||
// Use version number to decide which chapter to keep
|
// Use version number to decide which chapter to keep
|
||||||
@@ -274,37 +309,70 @@ abstract class SyncService(
|
|||||||
localCategoriesList: List<BackupCategory>?,
|
localCategoriesList: List<BackupCategory>?,
|
||||||
remoteCategoriesList: List<BackupCategory>?,
|
remoteCategoriesList: List<BackupCategory>?,
|
||||||
): List<BackupCategory> {
|
): List<BackupCategory> {
|
||||||
|
val logTag = "MergeCategories"
|
||||||
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
|
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
|
||||||
if (remoteCategoriesList == null) return localCategoriesList
|
if (remoteCategoriesList == null) return localCategoriesList
|
||||||
val localCategoriesMap = localCategoriesList.associateBy { it.name }
|
|
||||||
val remoteCategoriesMap = remoteCategoriesList.associateBy { it.name }
|
|
||||||
|
|
||||||
val mergedCategoriesMap = mutableMapOf<String, BackupCategory>()
|
val result = mutableListOf<BackupCategory>()
|
||||||
|
val processedLocals = mutableSetOf<BackupCategory>()
|
||||||
|
|
||||||
localCategoriesMap.forEach { (name, localCategory) ->
|
val localMapByUid = localCategoriesList.filter { it.uid != 0L }.associateBy { it.uid }
|
||||||
val remoteCategory = remoteCategoriesMap[name]
|
val localMapByName = localCategoriesList.associateBy { it.name }
|
||||||
if (remoteCategory != null) {
|
|
||||||
// Compare and merge local and remote categories
|
val lastSyncTime = syncPreferences.lastSyncTimestamp().get()
|
||||||
val mergedCategory = if (localCategory.order > remoteCategory.order) {
|
|
||||||
localCategory
|
remoteCategoriesList.forEach { remote ->
|
||||||
|
var localMatch: BackupCategory? = null
|
||||||
|
|
||||||
|
// 1. Try match by UID
|
||||||
|
if (remote.uid != 0L) {
|
||||||
|
localMatch = localMapByUid[remote.uid]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try match by Name (fallback)
|
||||||
|
if (localMatch == null) {
|
||||||
|
localMatch = localMapByName[remote.name]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localMatch != null) {
|
||||||
|
processedLocals.add(localMatch)
|
||||||
|
// Conflict resolution
|
||||||
|
if (localMatch.version >= remote.version) {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Keeping local category: ${localMatch.name} (UID: ${localMatch.uid})" }
|
||||||
|
result.add(localMatch)
|
||||||
} else {
|
} else {
|
||||||
remoteCategory
|
logcat(LogPriority.DEBUG, logTag) { "Keeping remote category: ${remote.name} (UID: ${remote.uid})" }
|
||||||
|
// Preserve Local UID if Remote was 0
|
||||||
|
if (remote.uid == 0L) {
|
||||||
|
remote.uid = localMatch.uid
|
||||||
|
}
|
||||||
|
result.add(remote)
|
||||||
}
|
}
|
||||||
mergedCategoriesMap[name] = mergedCategory
|
|
||||||
} else {
|
} else {
|
||||||
// If the category is only in the local list, add it to the merged list
|
val remoteModifiedTimeMillis = remote.lastModifiedAt.seconds.inWholeMilliseconds
|
||||||
mergedCategoriesMap[name] = localCategory
|
if (lastSyncTime == 0L || remoteModifiedTimeMillis > lastSyncTime) {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Adding new remote category: ${remote.name} (UID: ${remote.uid})" }
|
||||||
|
result.add(remote)
|
||||||
|
} else {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote category: ${remote.name} (UID: ${remote.uid})" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any categories from the remote list that are not in the local list
|
// Add remaining Local Categories
|
||||||
remoteCategoriesMap.forEach { (name, remoteCategory) ->
|
localCategoriesList.forEach { local ->
|
||||||
if (!mergedCategoriesMap.containsKey(name)) {
|
if (local !in processedLocals) {
|
||||||
mergedCategoriesMap[name] = remoteCategory
|
val localModifiedTimeMillis = local.lastModifiedAt.seconds.inWholeMilliseconds
|
||||||
|
if (lastSyncTime == 0L || localModifiedTimeMillis > lastSyncTime) {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Keeping local only category: ${local.name} (UID: ${local.uid})" }
|
||||||
|
result.add(local)
|
||||||
|
} else {
|
||||||
|
logcat(LogPriority.DEBUG, logTag) { "Dropping local category deleted on remote: ${local.name} (UID: ${local.uid})" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergedCategoriesMap.values.toList()
|
return result.sortedBy { it.order }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeSourcesLists(
|
private fun mergeSourcesLists(
|
||||||
@@ -341,8 +409,8 @@ abstract class SyncService(
|
|||||||
remoteSource
|
remoteSource
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logcat(LogPriority.DEBUG, logTag) { "Remote and local is not empty: $sourceId. Skipping." }
|
logcat(LogPriority.DEBUG, logTag) { "Remote and local have the same source ID: $sourceId. Keeping local." }
|
||||||
null
|
localSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,8 +455,8 @@ abstract class SyncService(
|
|||||||
remotePreference
|
remotePreference
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have keys. Skipping: $key" }
|
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same preference key: $key. Keeping local." }
|
||||||
null
|
localPreference
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,10 +575,8 @@ abstract class SyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logcat(LogPriority.DEBUG, logTag) {
|
logcat(LogPriority.DEBUG, logTag) { "Both remote and local have the same saved search key: $compositeKey. Keeping local." }
|
||||||
"No saved search found for composite key: $compositeKey. Skipping."
|
localSearch
|
||||||
}
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import eu.kanade.domain.sync.SyncPreferences
|
|||||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
import eu.kanade.tachiyomi.data.sync.SyncNotifier
|
import eu.kanade.tachiyomi.data.sync.SyncNotifier
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.PUT
|
import eu.kanade.tachiyomi.network.PUT
|
||||||
import eu.kanade.tachiyomi.network.await
|
import eu.kanade.tachiyomi.network.await
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
@@ -34,7 +40,26 @@ class SyncYomiSyncService(
|
|||||||
|
|
||||||
private class SyncYomiException(message: String?) : Exception(message)
|
private class SyncYomiException(message: String?) : Exception(message)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SyncEvent(
|
||||||
|
val event: SyncEventStatus,
|
||||||
|
@SerialName("device_name")
|
||||||
|
val deviceName: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private enum class SyncEventStatus {
|
||||||
|
SYNC_STARTED,
|
||||||
|
SYNC_SUCCESS,
|
||||||
|
SYNC_FAILED,
|
||||||
|
SYNC_ERROR,
|
||||||
|
SYNC_CANCELLED,
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun doSync(syncData: SyncData): Backup? {
|
override suspend fun doSync(syncData: SyncData): Backup? {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val (remoteData, etag) = pullSyncData()
|
val (remoteData, etag) = pullSyncData()
|
||||||
|
|
||||||
@@ -52,11 +77,23 @@ class SyncYomiSyncService(
|
|||||||
syncData
|
syncData
|
||||||
}
|
}
|
||||||
|
|
||||||
pushSyncData(finalSyncData, etag)
|
val success = pushSyncData(finalSyncData, etag)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
|
||||||
|
} else {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
|
||||||
|
}
|
||||||
|
|
||||||
return finalSyncData.backup
|
return finalSyncData.backup
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
|
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
|
||||||
notifier.showSyncError(e.message)
|
notifier.showSyncError(e.message)
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,8 +160,8 @@ class SyncYomiSyncService(
|
|||||||
/**
|
/**
|
||||||
* Return true if update success
|
* Return true if update success
|
||||||
*/
|
*/
|
||||||
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
|
private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
|
||||||
val backup = syncData.backup ?: return
|
val backup = syncData.backup ?: return true
|
||||||
|
|
||||||
val host = syncPreferences.clientHost().get()
|
val host = syncPreferences.clientHost().get()
|
||||||
val apiKey = syncPreferences.clientAPIKey().get()
|
val apiKey = syncPreferences.clientAPIKey().get()
|
||||||
@@ -163,13 +200,49 @@ class SyncYomiSyncService(
|
|||||||
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
|
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
|
||||||
syncPreferences.lastSyncEtag().set(newETag)
|
syncPreferences.lastSyncEtag().set(newETag)
|
||||||
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
|
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
|
||||||
|
return true
|
||||||
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
|
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
|
||||||
// other clients updated remote data, will try next time
|
// other clients updated remote data, will try next time
|
||||||
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
|
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
|
||||||
|
return false
|
||||||
} else {
|
} else {
|
||||||
val responseBody = response.body.string()
|
val responseBody = response.body.string()
|
||||||
notifier.showSyncError("Failed to upload sync data: $responseBody")
|
notifier.showSyncError("Failed to upload sync data: $responseBody")
|
||||||
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
|
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
try {
|
||||||
|
val host = syncPreferences.clientHost().get()
|
||||||
|
val apiKey = syncPreferences.clientAPIKey().get()
|
||||||
|
val url = "$host/api/sync/event"
|
||||||
|
|
||||||
|
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||||
|
val headers = headersBuilder.build()
|
||||||
|
|
||||||
|
val bodyObj = SyncEvent(
|
||||||
|
event = event,
|
||||||
|
deviceName = android.os.Build.MODEL,
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonBody = json.encodeToString(SyncEvent.serializer(), bodyObj)
|
||||||
|
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
|
||||||
|
val request = POST(
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = requestBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = OkHttpClient()
|
||||||
|
client.newCall(request).await().close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR) { "Failed to report sync event: ${e.message}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|
|
||||||
suspend fun addLibManga(track: Track): Track {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean) {
|
|mutation AddManga($mangaId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean) {
|
||||||
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private) {
|
|SaveMediaListEntry (mediaId: $mangaId, progress: $progress, status: $status, private: $private) {
|
||||||
| id
|
| id
|
||||||
| status
|
| status
|
||||||
|}
|
|}
|
||||||
@@ -82,14 +82,14 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|
|
||||||
suspend fun updateLibManga(track: Track): Track {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|mutation UpdateManga(
|
|mutation UpdateManga(
|
||||||
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean,
|
|$listId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean,
|
||||||
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput
|
|$score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput
|
||||||
|) {
|
|) {
|
||||||
|SaveMediaListEntry(
|
|SaveMediaListEntry(
|
||||||
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private,
|
|id: $listId, progress: $progress, status: $status, private: $private,
|
||||||
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt
|
|scoreRaw: $score, startedAt: $startedAt, completedAt: $completedAt
|
||||||
|) {
|
|) {
|
||||||
|id
|
|id
|
||||||
|status
|
|status
|
||||||
@@ -118,9 +118,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|
|
||||||
suspend fun deleteLibManga(track: DomainTrack) {
|
suspend fun deleteLibManga(track: DomainTrack) {
|
||||||
withIOContext {
|
withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|mutation DeleteManga(${'$'}listId: Int) {
|
|mutation DeleteManga($listId: Int) {
|
||||||
|DeleteMediaListEntry(id: ${'$'}listId) {
|
|DeleteMediaListEntry(id: $listId) {
|
||||||
|deleted
|
|deleted
|
||||||
|}
|
|}
|
||||||
|}
|
|}
|
||||||
@@ -139,10 +139,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|
|
||||||
suspend fun search(search: String): List<TrackSearch> {
|
suspend fun search(search: String): List<TrackSearch> {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|query Search(${'$'}query: String) {
|
|query Search($query: String) {
|
||||||
|Page (perPage: 50) {
|
|Page (perPage: 50) {
|
||||||
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
|media(search: $query, type: MANGA, format_not_in: [NOVEL]) {
|
||||||
|id
|
|id
|
||||||
|staff {
|
|staff {
|
||||||
|edges {
|
|edges {
|
||||||
@@ -201,10 +201,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|
|
||||||
suspend fun findLibManga(track: Track, userid: Int): Track? {
|
suspend fun findLibManga(track: Track, userid: Int): Track? {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
|query ($id: Int!, $manga_id: Int!) {
|
||||||
|Page {
|
|Page {
|
||||||
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|
|mediaList(userId: $id, type: MANGA, mediaId: $manga_id) {
|
||||||
|id
|
|id
|
||||||
|status
|
|status
|
||||||
|scoreRaw: score(format: POINT_100)
|
|scoreRaw: score(format: POINT_100)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
|
|||||||
// Users can set a 'username' (not nickname) once which effectively
|
// Users can set a 'username' (not nickname) once which effectively
|
||||||
// replaces the stringified ID in certain queries.
|
// replaces the stringified ID in certain queries.
|
||||||
// If no username is set, the API returns the user ID as a strings
|
// If no username is set, the API returns the user ID as a strings
|
||||||
var username = api.getUsername()
|
val username = api.getUsername()
|
||||||
saveCredentials(username, oauth.accessToken)
|
saveCredentials(username, oauth.accessToken)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
logout()
|
logout()
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authentication.apiUrl = prefApiUrl
|
authentication.apiUrl = prefApiUrl
|
||||||
authentication.jwtToken = token.toString()
|
authentication.jwtToken = token
|
||||||
}
|
}
|
||||||
authentications = oauth
|
authentications = oauth
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALMangaMetadata
|
|||||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
|
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
|
||||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult
|
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult
|
||||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser
|
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser
|
||||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUserSearchResult
|
|
||||||
import eu.kanade.tachiyomi.network.DELETE
|
import eu.kanade.tachiyomi.network.DELETE
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import eu.kanade.tachiyomi.util.PkceUtil
|
import eu.kanade.tachiyomi.util.PkceUtil
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
@@ -80,15 +77,15 @@ class MyAnimeListApi(
|
|||||||
// MAL API throws a 400 when the query is over 64 characters...
|
// MAL API throws a 400 when the query is over 64 characters...
|
||||||
.appendQueryParameter("q", query.take(64))
|
.appendQueryParameter("q", query.take(64))
|
||||||
.appendQueryParameter("nsfw", "true")
|
.appendQueryParameter("nsfw", "true")
|
||||||
|
.appendQueryParameter("fields", SEARCH_FIELDS)
|
||||||
.build()
|
.build()
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET(url.toString()))
|
authClient.newCall(GET(url.toString()))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<MALSearchResult>()
|
.parseAs<MALSearchResult>()
|
||||||
.data
|
.data
|
||||||
.map { async { getMangaDetails(it.node.id) } }
|
.filter { !(it.node.mediaType.contains("novel")) }
|
||||||
.awaitAll()
|
.map { parseSearchItem(it.node) }
|
||||||
.filter { !it.publishing_type.contains("novel") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,29 +94,13 @@ class MyAnimeListApi(
|
|||||||
return withIOContext {
|
return withIOContext {
|
||||||
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
||||||
.appendPath(id.toString())
|
.appendPath(id.toString())
|
||||||
.appendQueryParameter(
|
.appendQueryParameter("fields", SEARCH_FIELDS)
|
||||||
"fields",
|
|
||||||
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date",
|
|
||||||
)
|
|
||||||
.build()
|
.build()
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET(url.toString()))
|
authClient.newCall(GET(url.toString()))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<MALManga>()
|
.parseAs<MALManga>()
|
||||||
.let {
|
.let { parseSearchItem(it) }
|
||||||
TrackSearch.create(trackId).apply {
|
|
||||||
remote_id = it.id
|
|
||||||
title = it.title
|
|
||||||
summary = it.synopsis
|
|
||||||
total_chapters = it.numChapters
|
|
||||||
score = it.mean
|
|
||||||
cover_url = it.covers?.large.orEmpty()
|
|
||||||
tracking_url = "https://myanimelist.net/manga/$remote_id"
|
|
||||||
publishing_status = it.status.replace("_", " ")
|
|
||||||
publishing_type = it.mediaType.replace("_", " ")
|
|
||||||
start_date = it.startDate ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,8 +164,7 @@ class MyAnimeListApi(
|
|||||||
|
|
||||||
val matches = myListSearchResult.data
|
val matches = myListSearchResult.data
|
||||||
.filter { it.node.title.contains(query, ignoreCase = true) }
|
.filter { it.node.title.contains(query, ignoreCase = true) }
|
||||||
.map { async { getMangaDetails(it.node.id) } }
|
.map { parseSearchItem(it.node) }
|
||||||
.awaitAll()
|
|
||||||
|
|
||||||
// Check next page if there's more
|
// Check next page if there's more
|
||||||
if (!myListSearchResult.paging.next.isNullOrBlank()) {
|
if (!myListSearchResult.paging.next.isNullOrBlank()) {
|
||||||
@@ -216,12 +196,12 @@ class MyAnimeListApi(
|
|||||||
description = it.synopsis,
|
description = it.synopsis,
|
||||||
authors = it.authors
|
authors = it.authors
|
||||||
.filter { it.role == "Story" || it.role == "Story & Art" }
|
.filter { it.role == "Story" || it.role == "Story & Art" }
|
||||||
.map { "${it.node.firstName} ${it.node.lastName}".trim() }
|
.mapNotNull { it.node.getFullName() }
|
||||||
.joinToString(separator = ", ")
|
.joinToString(separator = ", ")
|
||||||
.ifEmpty { null },
|
.ifEmpty { null },
|
||||||
artists = it.authors
|
artists = it.authors
|
||||||
.filter { it.role == "Art" || it.role == "Story & Art" }
|
.filter { it.role == "Art" || it.role == "Story & Art" }
|
||||||
.map { "${it.node.firstName} ${it.node.lastName}".trim() }
|
.mapNotNull { it.node.getFullName() }
|
||||||
.joinToString(separator = ", ")
|
.joinToString(separator = ", ")
|
||||||
.ifEmpty { null },
|
.ifEmpty { null },
|
||||||
)
|
)
|
||||||
@@ -230,10 +210,10 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getListPage(offset: Int): MALUserSearchResult {
|
private suspend fun getListPage(offset: Int): MALSearchResult {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
|
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
|
||||||
.appendQueryParameter("fields", "list_status{start_date,finish_date}")
|
.appendQueryParameter("fields", SEARCH_FIELDS)
|
||||||
.appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString())
|
.appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString())
|
||||||
if (offset > 0) {
|
if (offset > 0) {
|
||||||
urlBuilder.appendQueryParameter("offset", offset.toString())
|
urlBuilder.appendQueryParameter("offset", offset.toString())
|
||||||
@@ -262,6 +242,28 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseSearchItem(searchItem: MALManga): TrackSearch {
|
||||||
|
return TrackSearch.create(trackId).apply {
|
||||||
|
remote_id = searchItem.id
|
||||||
|
title = searchItem.title
|
||||||
|
summary = searchItem.synopsis
|
||||||
|
total_chapters = searchItem.numChapters
|
||||||
|
score = searchItem.mean
|
||||||
|
cover_url = searchItem.covers?.large.orEmpty()
|
||||||
|
tracking_url = "https://myanimelist.net/manga/$remote_id"
|
||||||
|
publishing_status = searchItem.status.replace("_", " ")
|
||||||
|
publishing_type = searchItem.mediaType.replace("_", " ")
|
||||||
|
start_date = searchItem.startDate ?: ""
|
||||||
|
artists = searchItem.authors
|
||||||
|
.filter { authorNode -> authorNode.role == "Art" }
|
||||||
|
.mapNotNull { authorNode -> authorNode.node.getFullName() }
|
||||||
|
authors = searchItem.authors
|
||||||
|
// count all with "Story" or "Story & Art" as authors, like is done for library entries
|
||||||
|
.filter { authorNode -> authorNode.role.contains("Story") }
|
||||||
|
.mapNotNull { authorNode -> authorNode.node.getFullName() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseDate(isoDate: String): Long {
|
private fun parseDate(isoDate: String): Long {
|
||||||
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
|
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
|
||||||
}
|
}
|
||||||
@@ -273,7 +275,7 @@ class MyAnimeListApi(
|
|||||||
return try {
|
return try {
|
||||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
outputDf.format(epochTime)
|
outputDf.format(epochTime)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +286,9 @@ class MyAnimeListApi(
|
|||||||
private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2"
|
private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2"
|
||||||
private const val BASE_API_URL = "https://api.myanimelist.net/v2"
|
private const val BASE_API_URL = "https://api.myanimelist.net/v2"
|
||||||
|
|
||||||
|
private const val SEARCH_FIELDS =
|
||||||
|
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date,authors{first_name,last_name}"
|
||||||
|
|
||||||
private const val LIST_PAGINATION_AMOUNT = 250
|
private const val LIST_PAGINATION_AMOUNT = 250
|
||||||
|
|
||||||
private var codeVerifier: String = ""
|
private var codeVerifier: String = ""
|
||||||
|
|||||||
@@ -18,8 +18,26 @@ data class MALManga(
|
|||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
@SerialName("start_date")
|
@SerialName("start_date")
|
||||||
val startDate: String?,
|
val startDate: String?,
|
||||||
|
val authors: List<MALAuthorNode> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MALAuthorNode(
|
||||||
|
val node: MALAuthor,
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MALAuthor(
|
||||||
|
val id: Int,
|
||||||
|
@SerialName("first_name")
|
||||||
|
val firstName: String,
|
||||||
|
@SerialName("last_name")
|
||||||
|
val lastName: String,
|
||||||
|
) {
|
||||||
|
fun getFullName(): String? = "$firstName $lastName".trim().ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MALMangaCovers(
|
data class MALMangaCovers(
|
||||||
val large: String = "",
|
val large: String = "",
|
||||||
@@ -33,19 +51,5 @@ data class MALMangaMetadata(
|
|||||||
val synopsis: String?,
|
val synopsis: String?,
|
||||||
@SerialName("main_picture")
|
@SerialName("main_picture")
|
||||||
val covers: MALMangaCovers,
|
val covers: MALMangaCovers,
|
||||||
val authors: List<MALAuthor>,
|
val authors: List<MALAuthorNode>,
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MALAuthor(
|
|
||||||
val node: MALAuthorNode,
|
|
||||||
val role: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MALAuthorNode(
|
|
||||||
@SerialName("first_name")
|
|
||||||
val firstName: String,
|
|
||||||
@SerialName("last_name")
|
|
||||||
val lastName: String,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class MALSearchResult(
|
data class MALSearchResult(
|
||||||
val data: List<MALSearchResultNode>,
|
val data: List<MALSearchResultNode>,
|
||||||
|
val paging: MALSearchPaging,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MALSearchResultNode(
|
data class MALSearchResultNode(
|
||||||
val node: MALSearchResultItem,
|
val node: MALManga,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MALSearchResultItem(
|
data class MALSearchPaging(
|
||||||
val id: Int,
|
val next: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MALUserSearchResult(
|
|
||||||
val data: List<MALUserSearchItem>,
|
|
||||||
val paging: MALUserSearchPaging,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MALUserSearchItem(
|
|
||||||
val node: MALUserSearchItemNode,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MALUserSearchPaging(
|
|
||||||
val next: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MALUserSearchItemNode(
|
|
||||||
val id: Int,
|
|
||||||
val title: String,
|
|
||||||
)
|
|
||||||
@@ -37,14 +37,14 @@ class SuwayomiApi(private val trackId: Long) {
|
|||||||
public fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
|
public fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
|
||||||
|
|
||||||
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
|
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|query GetManga(${'$'}mangaId: Int!) {
|
|query GetManga($mangaId: Int!) {
|
||||||
| manga(id: ${'$'}mangaId) {
|
| manga(id: $mangaId) {
|
||||||
| ...MangaFragment
|
| ...MangaFragment
|
||||||
| }
|
| }
|
||||||
|}
|
|}
|
||||||
|
|
|
|
||||||
|$MangaFragment
|
|$$MangaFragment
|
||||||
""".trimMargin()
|
""".trimMargin()
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
put("query", query)
|
put("query", query)
|
||||||
@@ -87,9 +87,9 @@ class SuwayomiApi(private val trackId: Long) {
|
|||||||
|
|
||||||
// TODO: Include a filter on the chapter number here
|
// TODO: Include a filter on the chapter number here
|
||||||
// Below, we only consider older chapters; since v2.1.1985 filtering works properly in the query
|
// Below, we only consider older chapters; since v2.1.1985 filtering works properly in the query
|
||||||
val chaptersQuery = """
|
val chaptersQuery = $$"""
|
||||||
|query GetMangaUnreadChapters(${'$'}mangaId: Int!) {
|
|query GetMangaUnreadChapters($mangaId: Int!) {
|
||||||
| chapters(condition: {mangaId: ${'$'}mangaId, isRead: false}) {
|
| chapters(condition: {mangaId: $mangaId, isRead: false}) {
|
||||||
| nodes {
|
| nodes {
|
||||||
| id
|
| id
|
||||||
| chapterNumber
|
| chapterNumber
|
||||||
@@ -115,24 +115,24 @@ class SuwayomiApi(private val trackId: Long) {
|
|||||||
.data
|
.data
|
||||||
.entry
|
.entry
|
||||||
.nodes
|
.nodes
|
||||||
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read } }
|
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read + 0.001 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
val markQuery = if (deleteDownloadsOnServer) {
|
val markQuery = if (deleteDownloadsOnServer) {
|
||||||
"""
|
$$"""
|
||||||
|mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
|
|mutation MarkChaptersRead($chapters: [Int!]!) {
|
||||||
| updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
|
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
|
||||||
| __typename
|
| __typename
|
||||||
| }
|
| }
|
||||||
| deleteDownloadedChapters(input: {ids: ${'$'}chapters}) {
|
| deleteDownloadedChapters(input: {ids: $chapters}) {
|
||||||
| __typename
|
| __typename
|
||||||
| }
|
| }
|
||||||
|}
|
|}
|
||||||
""".trimMargin()
|
""".trimMargin()
|
||||||
} else {
|
} else {
|
||||||
"""
|
$$"""
|
||||||
|mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
|
|mutation MarkChaptersRead($chapters: [Int!]!) {
|
||||||
| updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
|
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
|
||||||
| __typename
|
| __typename
|
||||||
| }
|
| }
|
||||||
|}
|
|}
|
||||||
@@ -156,9 +156,9 @@ class SuwayomiApi(private val trackId: Long) {
|
|||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
val trackQuery = """
|
val trackQuery = $$"""
|
||||||
|mutation TrackManga(${'$'}mangaId: Int!) {
|
|mutation TrackManga($mangaId: Int!) {
|
||||||
| trackProgress(input: {mangaId: ${'$'}mangaId}) {
|
| trackProgress(input: {mangaId: $mangaId}) {
|
||||||
| __typename
|
| __typename
|
||||||
| }
|
| }
|
||||||
|}
|
|}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package eu.kanade.tachiyomi.di
|
package eu.kanade.tachiyomi.di
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||||
|
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
|
||||||
|
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
|
||||||
|
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
|
||||||
|
import com.eygraber.sqldelight.androidx.driver.FileProvider
|
||||||
import eu.kanade.domain.track.store.DelayedTrackingStore
|
import eu.kanade.domain.track.store.DelayedTrackingStore
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||||
@@ -25,7 +28,6 @@ import eu.kanade.tachiyomi.network.NetworkHelper
|
|||||||
import eu.kanade.tachiyomi.source.AndroidSourceManager
|
import eu.kanade.tachiyomi.source.AndroidSourceManager
|
||||||
import eu.kanade.tachiyomi.util.storage.CbzCrypto
|
import eu.kanade.tachiyomi.util.storage.CbzCrypto
|
||||||
import exh.eh.EHentaiUpdateHelper
|
import exh.eh.EHentaiUpdateHelper
|
||||||
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||||
@@ -52,10 +54,6 @@ import uy.kohesive.injekt.api.InjektRegistrar
|
|||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
// SY -->
|
|
||||||
private const val LEGACY_DATABASE_NAME = "tachiyomi.db"
|
|
||||||
// SY <--
|
|
||||||
|
|
||||||
class AppModule(val app: Application) : InjektModule {
|
class AppModule(val app: Application) : InjektModule {
|
||||||
// SY -->
|
// SY -->
|
||||||
private val securityPreferences: SecurityPreferences by injectLazy()
|
private val securityPreferences: SecurityPreferences by injectLazy()
|
||||||
@@ -68,40 +66,37 @@ class AppModule(val app: Application) : InjektModule {
|
|||||||
// SY -->
|
// SY -->
|
||||||
if (securityPreferences.encryptDatabase().get()) {
|
if (securityPreferences.encryptDatabase().get()) {
|
||||||
System.loadLibrary("sqlcipher")
|
System.loadLibrary("sqlcipher")
|
||||||
}
|
|
||||||
|
|
||||||
|
return@addSingletonFactory AndroidSqliteDriver(
|
||||||
|
schema = Database.Schema,
|
||||||
|
context = app,
|
||||||
|
name = CbzCrypto.DATABASE_NAME,
|
||||||
|
factory = SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql(), null, false, 25),
|
||||||
|
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
|
||||||
|
override fun onOpen(db: SupportSQLiteDatabase) {
|
||||||
|
super.onOpen(db)
|
||||||
|
setPragma(db, "foreign_keys = ON")
|
||||||
|
setPragma(db, "journal_mode = WAL")
|
||||||
|
setPragma(db, "synchronous = NORMAL")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
|
||||||
|
val cursor = db.query("PRAGMA $pragma")
|
||||||
|
cursor.moveToFirst()
|
||||||
|
cursor.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
// SY <--
|
// SY <--
|
||||||
AndroidSqliteDriver(
|
|
||||||
|
AndroidxSqliteDriver(
|
||||||
|
driver = BundledSQLiteDriver(),
|
||||||
|
databaseType = AndroidxSqliteDatabaseType.FileProvider(app, "tachiyomi.db"),
|
||||||
schema = Database.Schema,
|
schema = Database.Schema,
|
||||||
context = app,
|
configuration = AndroidxSqliteConfiguration(
|
||||||
// SY -->
|
isForeignKeyConstraintsEnabled = true,
|
||||||
name = if (securityPreferences.encryptDatabase().get()) {
|
),
|
||||||
CbzCrypto.DATABASE_NAME
|
|
||||||
} else {
|
|
||||||
LEGACY_DATABASE_NAME
|
|
||||||
},
|
|
||||||
factory = if (securityPreferences.encryptDatabase().get()) {
|
|
||||||
SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql(), null, false, 25)
|
|
||||||
} else if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
// Support database inspector in Android Studio
|
|
||||||
FrameworkSQLiteOpenHelperFactory()
|
|
||||||
} else {
|
|
||||||
RequerySQLiteOpenHelperFactory()
|
|
||||||
},
|
|
||||||
// SY <--
|
|
||||||
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
|
|
||||||
override fun onOpen(db: SupportSQLiteDatabase) {
|
|
||||||
super.onOpen(db)
|
|
||||||
setPragma(db, "foreign_keys = ON")
|
|
||||||
setPragma(db, "journal_mode = WAL")
|
|
||||||
setPragma(db, "synchronous = NORMAL")
|
|
||||||
}
|
|
||||||
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
|
|
||||||
val cursor = db.query("PRAGMA $pragma")
|
|
||||||
cursor.moveToFirst()
|
|
||||||
cursor.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
|
|||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.storage.service.StoragePreferences
|
import tachiyomi.domain.storage.service.StoragePreferences
|
||||||
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
import uy.kohesive.injekt.api.InjektRegistrar
|
import uy.kohesive.injekt.api.InjektRegistrar
|
||||||
|
|
||||||
class PreferenceModule(val app: Application) : InjektModule {
|
class PreferenceModule(val app: Application) : InjektModule {
|
||||||
@@ -44,6 +45,9 @@ class PreferenceModule(val app: Application) : InjektModule {
|
|||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
LibraryPreferences(get())
|
LibraryPreferences(get())
|
||||||
}
|
}
|
||||||
|
addSingletonFactory {
|
||||||
|
UpdatesPreferences(get())
|
||||||
|
}
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
ReaderPreferences(get())
|
ReaderPreferences(get())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
import tachiyomi.core.common.util.lang.withUIContext
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
@@ -140,20 +141,22 @@ class ExtensionManager(
|
|||||||
* Loads and registers the installed extensions.
|
* Loads and registers the installed extensions.
|
||||||
*/
|
*/
|
||||||
private fun initExtensions() {
|
private fun initExtensions() {
|
||||||
val extensions = ExtensionLoader.loadExtensions(context)
|
scope.launch {
|
||||||
|
val extensions = ExtensionLoader.loadExtensions(context)
|
||||||
|
|
||||||
installedExtensionMapFlow.value = extensions
|
installedExtensionMapFlow.value = extensions
|
||||||
.filterIsInstance<LoadResult.Success>()
|
.filterIsInstance<LoadResult.Success>()
|
||||||
.associate { it.extension.pkgName to it.extension }
|
.associate { it.extension.pkgName to it.extension }
|
||||||
|
|
||||||
untrustedExtensionMapFlow.value = extensions
|
untrustedExtensionMapFlow.value = extensions
|
||||||
.filterIsInstance<LoadResult.Untrusted>()
|
.filterIsInstance<LoadResult.Untrusted>()
|
||||||
.associate { it.extension.pkgName to it.extension }
|
.associate { it.extension.pkgName to it.extension }
|
||||||
// SY -->
|
// SY -->
|
||||||
.filterNotBlacklisted()
|
.filterNotBlacklisted()
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
_isInitialized.value = true
|
_isInitialized.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EXH -->
|
// EXH -->
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.extension.installer
|
package eu.kanade.tachiyomi.extension.installer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
@@ -82,6 +83,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
inputStream.copyTo(outputStream)
|
inputStream.copyTo(outputStream)
|
||||||
session.fsync(outputStream)
|
session.fsync(outputStream)
|
||||||
}
|
}
|
||||||
|
service.contentResolver.delete(entry.uri, null, null)
|
||||||
|
|
||||||
val intentSender = PendingIntent.getBroadcast(
|
val intentSender = PendingIntent.getBroadcast(
|
||||||
service,
|
service,
|
||||||
@@ -89,6 +91,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
Intent(INSTALL_ACTION).setPackage(service.packageName),
|
Intent(INSTALL_ACTION).setPackage(service.packageName),
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
||||||
).intentSender
|
).intentSender
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
session.commit(intentSender)
|
session.commit(intentSender)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -109,9 +109,10 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
|
|||||||
override fun processEntry(entry: Entry) {
|
override fun processEntry(entry: Entry) {
|
||||||
super.processEntry(entry)
|
super.processEntry(entry)
|
||||||
try {
|
try {
|
||||||
shellInterface?.install(
|
service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
|
||||||
service.contentResolver.openAssetFileDescriptor(entry.uri, "r"),
|
shellInterface?.install(it)
|
||||||
)
|
}
|
||||||
|
service.contentResolver.delete(entry.uri, null, null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||||
continueQueue(InstallStep.Error)
|
continueQueue(InstallStep.Error)
|
||||||
@@ -124,7 +125,13 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Shizuku.removeBinderDeadListener(shizukuDeadListener)
|
Shizuku.removeBinderDeadListener(shizukuDeadListener)
|
||||||
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
|
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
|
||||||
Shizuku.unbindUserService(shizukuArgs, connection, true)
|
if (Shizuku.pingBinder()) {
|
||||||
|
try {
|
||||||
|
Shizuku.unbindUserService(shizukuArgs, connection, true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.WARN, e) { "Failed to unbind shizuku service" }
|
||||||
|
}
|
||||||
|
}
|
||||||
service.unregisterReceiver(receiver)
|
service.unregisterReceiver(receiver)
|
||||||
logcat { "ShizukuInstaller destroy" }
|
logcat { "ShizukuInstaller destroy" }
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class ExtensionInstallActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
intent.data?.let { contentResolver.delete(it, null, null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun checkInstallationResult(resultCode: Int) {
|
private fun checkInstallationResult(resultCode: Int) {
|
||||||
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||||
val extensionManager = Injekt.get<ExtensionManager>()
|
val extensionManager = Injekt.get<ExtensionManager>()
|
||||||
|
|||||||
@@ -1,66 +1,48 @@
|
|||||||
package eu.kanade.tachiyomi.extension.util
|
package eu.kanade.tachiyomi.extension.util
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import eu.kanade.domain.base.BasePreferences
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
|
||||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import kotlinx.coroutines.flow.merge
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installer which installs, updates and uninstalls the extensions.
|
* The installer which installs, updates and uninstalls the extensions.
|
||||||
*
|
*
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
*/
|
*/
|
||||||
internal class ExtensionInstaller(private val context: Context) {
|
internal class ExtensionInstaller(
|
||||||
|
private val context: Context,
|
||||||
/**
|
) {
|
||||||
* The system's download manager
|
|
||||||
*/
|
|
||||||
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The broadcast receiver which listens to download completion events.
|
|
||||||
*/
|
|
||||||
private val downloadReceiver = DownloadCompletionReceiver()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The currently requested downloads, with the package name (unique id) as key, and the id
|
|
||||||
* returned by the download manager.
|
|
||||||
*/
|
|
||||||
private val activeDownloads = hashMapOf<String, Long>()
|
|
||||||
|
|
||||||
private val downloadsStateFlows = hashMapOf<Long, MutableStateFlow<InstallStep>>()
|
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
private val activeJobs = mutableMapOf<String, Job>()
|
||||||
|
private val activeSteps = mutableMapOf<Long, MutableStateFlow<InstallStep>>()
|
||||||
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
||||||
|
|
||||||
|
private val httpClient: OkHttpClient = Injekt.get<NetworkHelper>().client
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||||
* step in the installation process.
|
* step in the installation process.
|
||||||
@@ -69,129 +51,86 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||||||
* @param extension The extension to install.
|
* @param extension The extension to install.
|
||||||
*/
|
*/
|
||||||
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
|
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
|
||||||
val pkgName = extension.pkgName
|
val downloadId = extension.pkgName.hashCode().toLong()
|
||||||
|
cancelInstall(extension.pkgName)
|
||||||
|
|
||||||
val oldDownload = activeDownloads[pkgName]
|
val step = MutableStateFlow(InstallStep.Pending)
|
||||||
if (oldDownload != null) {
|
activeSteps[downloadId] = step
|
||||||
deleteDownload(pkgName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the receiver after removing (and unregistering) the previous download
|
val job = scope.launch {
|
||||||
downloadReceiver.register()
|
val tmpFile = File(context.cacheDir, "extension_${extension.pkgName}.apk")
|
||||||
|
try {
|
||||||
|
step.value = InstallStep.Downloading
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val response = httpClient.newCall(request).execute()
|
||||||
|
|
||||||
val downloadUri = url.toUri()
|
if (!response.isSuccessful) {
|
||||||
val request = DownloadManager.Request(downloadUri)
|
throw Exception("Failed to download extension")
|
||||||
.setTitle(extension.name)
|
}
|
||||||
.setMimeType(APK_MIME)
|
response.body.byteStream().use { input ->
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
tmpFile.outputStream().use { output ->
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val id = downloadManager.enqueue(request)
|
step.value = InstallStep.Installing
|
||||||
activeDownloads[pkgName] = id
|
installApk(downloadId, tmpFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
val downloadStateFlow = MutableStateFlow(InstallStep.Pending)
|
if (e is InterruptedException) {
|
||||||
downloadsStateFlows[id] = downloadStateFlow
|
// Canceled
|
||||||
|
} else {
|
||||||
// Poll download status
|
logcat(LogPriority.ERROR, e)
|
||||||
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus ->
|
step.value = InstallStep.Error
|
||||||
// Map to our model
|
}
|
||||||
when (downloadStatus) {
|
|
||||||
DownloadManager.STATUS_PENDING -> InstallStep.Pending
|
|
||||||
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return merge(downloadStateFlow, pollStatusFlow).transformWhile {
|
activeJobs[extension.pkgName] = job
|
||||||
emit(it)
|
|
||||||
// Stop when the application is installed or errors
|
return step.asStateFlow()
|
||||||
!it.isCompleted()
|
.onCompletion {
|
||||||
}.onCompletion {
|
activeJobs.remove(extension.pkgName)
|
||||||
// Always notify on main thread
|
activeSteps.remove(downloadId)
|
||||||
withUIContext {
|
job.cancel()
|
||||||
// Always remove the download when unsubscribed
|
|
||||||
deleteDownload(pkgName)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a flow that polls the given download id for its status every second, as the
|
|
||||||
* manager doesn't have any notification system. It'll stop once the download finishes.
|
|
||||||
*
|
|
||||||
* @param id The id of the download to poll.
|
|
||||||
*/
|
|
||||||
private fun downloadStatusFlow(id: Long): Flow<Int> = flow {
|
|
||||||
val query = DownloadManager.Query().setFilterById(id)
|
|
||||||
while (true) {
|
|
||||||
// Get the current download status
|
|
||||||
val downloadStatus = downloadManager.query(query).use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) return@flow
|
|
||||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(downloadStatus)
|
|
||||||
|
|
||||||
// Stop polling when the download fails or finishes
|
|
||||||
if (
|
|
||||||
downloadStatus == DownloadManager.STATUS_SUCCESSFUL ||
|
|
||||||
downloadStatus == DownloadManager.STATUS_FAILED
|
|
||||||
) {
|
|
||||||
return@flow
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1.seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ignore duplicate results
|
|
||||||
.distinctUntilChanged()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts an intent to install the extension at the given uri.
|
* Starts an intent to install the extension at the given uri.
|
||||||
*
|
*
|
||||||
* @param uri The uri of the extension to install.
|
* @param tempFile The file of the extension to install. Delete after use.
|
||||||
*/
|
*/
|
||||||
fun installApk(downloadId: Long, uri: Uri) {
|
private fun installApk(downloadId: Long, tempFile: File) {
|
||||||
when (val installer = extensionInstaller.get()) {
|
when (val installer = extensionInstaller.get()) {
|
||||||
BasePreferences.ExtensionInstaller.LEGACY -> {
|
BasePreferences.ExtensionInstaller.LEGACY -> {
|
||||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||||
.setDataAndType(uri, APK_MIME)
|
.setDataAndType(tempFile.getUriCompat(context), APK_MIME)
|
||||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
||||||
val extensionManager = Injekt.get<ExtensionManager>()
|
|
||||||
val tempFile = File(context.cacheDir, "temp_$downloadId")
|
|
||||||
|
|
||||||
if (tempFile.exists() && !tempFile.delete()) {
|
|
||||||
// Unlikely but just in case
|
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
|
updateInstallStep(downloadId, InstallStep.Installed)
|
||||||
} else {
|
} else {
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
updateInstallStep(downloadId, InstallStep.Error)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
updateInstallStep(downloadId, InstallStep.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
val intent = ExtensionInstallService.getIntent(
|
||||||
|
context,
|
||||||
|
downloadId,
|
||||||
|
tempFile.getUriCompat(context),
|
||||||
|
installer,
|
||||||
|
)
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,9 +140,8 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||||||
* Cancels extension install and remove from download manager and installer.
|
* Cancels extension install and remove from download manager and installer.
|
||||||
*/
|
*/
|
||||||
fun cancelInstall(pkgName: String) {
|
fun cancelInstall(pkgName: String) {
|
||||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
activeJobs.remove(pkgName)?.cancel()
|
||||||
downloadManager.remove(downloadId)
|
Installer.cancelInstallQueue(context, pkgName.hashCode().toLong())
|
||||||
Installer.cancelInstallQueue(context, downloadId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,91 +168,11 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||||||
* @param step New install step.
|
* @param step New install step.
|
||||||
*/
|
*/
|
||||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||||
downloadsStateFlows[downloadId]?.let { it.value = step }
|
activeSteps[downloadId]?.let { it.value = step }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the download for the given package name.
|
|
||||||
*
|
|
||||||
* @param pkgName The package name of the download to delete.
|
|
||||||
*/
|
|
||||||
private fun deleteDownload(pkgName: String) {
|
|
||||||
val downloadId = activeDownloads.remove(pkgName)
|
|
||||||
if (downloadId != null) {
|
|
||||||
downloadManager.remove(downloadId)
|
|
||||||
downloadsStateFlows.remove(downloadId)
|
|
||||||
}
|
|
||||||
if (activeDownloads.isEmpty()) {
|
|
||||||
downloadReceiver.unregister()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receiver that listens to download status events.
|
|
||||||
*/
|
|
||||||
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this receiver is currently registered.
|
|
||||||
*/
|
|
||||||
private var isRegistered = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers this receiver if it's not already.
|
|
||||||
*/
|
|
||||||
fun register() {
|
|
||||||
if (isRegistered) return
|
|
||||||
isRegistered = true
|
|
||||||
|
|
||||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
|
||||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unregisters this receiver if it's not already.
|
|
||||||
*/
|
|
||||||
fun unregister() {
|
|
||||||
if (!isRegistered) return
|
|
||||||
isRegistered = false
|
|
||||||
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a download event is received. It looks for the download in the current active
|
|
||||||
* downloads and notifies its installation step.
|
|
||||||
*/
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
|
||||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
|
||||||
|
|
||||||
// Avoid events for downloads we didn't request
|
|
||||||
if (id !in activeDownloads.values) return
|
|
||||||
|
|
||||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
|
||||||
|
|
||||||
// Set next installation step
|
|
||||||
if (uri == null) {
|
|
||||||
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
|
||||||
updateInstallStep(id, InstallStep.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val query = DownloadManager.Query().setFilterById(id)
|
|
||||||
downloadManager.query(query).use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val localUri = cursor.getString(
|
|
||||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
|
||||||
).removePrefix(FILE_SCHEME)
|
|
||||||
|
|
||||||
installApk(id, File(localUri).getUriCompat(context))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val APK_MIME = "application/vnd.android.package-archive"
|
const val APK_MIME = "application/vnd.android.package-archive"
|
||||||
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
|
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
|
||||||
const val FILE_SCHEME = "file://"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
|||||||
import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader
|
import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
@@ -114,7 +115,7 @@ internal object ExtensionLoader {
|
|||||||
*
|
*
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
*/
|
*/
|
||||||
fun loadExtensions(context: Context): List<LoadResult> {
|
suspend fun loadExtensions(context: Context): List<LoadResult> {
|
||||||
val pkgManager = context.packageManager
|
val pkgManager = context.packageManager
|
||||||
|
|
||||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
@@ -160,11 +161,10 @@ internal object ExtensionLoader {
|
|||||||
if (extPkgs.isEmpty()) return emptyList()
|
if (extPkgs.isEmpty()) return emptyList()
|
||||||
|
|
||||||
// Load each extension concurrently and wait for completion
|
// Load each extension concurrently and wait for completion
|
||||||
return runBlocking {
|
return coroutineScope {
|
||||||
val deferred = extPkgs.map {
|
extPkgs.map {
|
||||||
async { loadExtension(context, it) }
|
async { loadExtension(context, it) }
|
||||||
}
|
}.awaitAll()
|
||||||
deferred.awaitAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.CacheControl
|
import okhttp3.CacheControl
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
|
|
||||||
class NHentai(delegate: HttpSource, val context: Context) :
|
class NHentai(delegate: HttpSource, val context: Context) :
|
||||||
DelegatedHttpSource(delegate),
|
DelegatedHttpSource(delegate),
|
||||||
@@ -70,12 +71,8 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
|
override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
|
||||||
val body = input.body.string()
|
if (nhConfig == null) getNhConfig()
|
||||||
val server = MEDIA_SERVER_REGEX.find(body)?.groupValues?.get(1)?.toInt() ?: 1
|
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(input.body.string())
|
||||||
val json = GALLERY_JSON_REGEX.find(body)!!.groupValues[1].replace(
|
|
||||||
UNICODE_ESCAPE_REGEX,
|
|
||||||
) { it.groupValues[1].toInt(radix = 16).toChar().toString() }
|
|
||||||
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(json)
|
|
||||||
|
|
||||||
with(metadata) {
|
with(metadata) {
|
||||||
nhId = jsonResponse.id
|
nhId = jsonResponse.id
|
||||||
@@ -86,8 +83,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
|
|
||||||
mediaId = jsonResponse.mediaId
|
mediaId = jsonResponse.mediaId
|
||||||
|
|
||||||
mediaServer = server
|
|
||||||
|
|
||||||
jsonResponse.title?.let { title ->
|
jsonResponse.title?.let { title ->
|
||||||
japaneseTitle = title.japanese
|
japaneseTitle = title.japanese
|
||||||
shortTitle = title.pretty
|
shortTitle = title.pretty
|
||||||
@@ -96,15 +91,11 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
|
|
||||||
preferredTitle = this@NHentai.preferredTitle
|
preferredTitle = this@NHentai.preferredTitle
|
||||||
|
|
||||||
jsonResponse.images?.let { images ->
|
coverImageUrl =
|
||||||
coverImageType = images.cover?.type
|
jsonResponse.cover?.path?.let { "$thumbServer/$it" }
|
||||||
images.pages.mapNotNull {
|
?: jsonResponse.thumbnail?.path?.let { "$thumbServer/$it" }
|
||||||
it.type
|
|
||||||
}.let {
|
pageImagePreviewUrls = jsonResponse.pages.mapNotNull { it.thumbnail }
|
||||||
pageImageTypes = it
|
|
||||||
}
|
|
||||||
thumbnailImageType = images.thumbnail?.type
|
|
||||||
}
|
|
||||||
|
|
||||||
scanlator = jsonResponse.scanlator?.trimOrNull()
|
scanlator = jsonResponse.scanlator?.trimOrNull()
|
||||||
|
|
||||||
@@ -125,13 +116,22 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class JsonConfig(
|
||||||
|
@SerialName("image_servers")
|
||||||
|
val imageServers: List<String> = emptyList(),
|
||||||
|
@SerialName("thumb_servers")
|
||||||
|
val thumbServers: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JsonResponse(
|
data class JsonResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@SerialName("media_id")
|
@SerialName("media_id")
|
||||||
val mediaId: String? = null,
|
val mediaId: String? = null,
|
||||||
val title: JsonTitle? = null,
|
val title: JsonTitle? = null,
|
||||||
val images: JsonImages? = null,
|
val cover: JsonPage? = null,
|
||||||
|
val thumbnail: JsonPage? = null,
|
||||||
val scanlator: String? = null,
|
val scanlator: String? = null,
|
||||||
@SerialName("upload_date")
|
@SerialName("upload_date")
|
||||||
val uploadDate: Long? = null,
|
val uploadDate: Long? = null,
|
||||||
@@ -140,6 +140,7 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
val numPages: Int? = null,
|
val numPages: Int? = null,
|
||||||
@SerialName("num_favorites")
|
@SerialName("num_favorites")
|
||||||
val numFavorites: Long? = null,
|
val numFavorites: Long? = null,
|
||||||
|
val pages: List<JsonPage> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -149,21 +150,12 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
val pretty: String? = null,
|
val pretty: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class JsonImages(
|
|
||||||
val pages: List<JsonPage> = emptyList(),
|
|
||||||
val cover: JsonPage? = null,
|
|
||||||
val thumbnail: JsonPage? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JsonPage(
|
data class JsonPage(
|
||||||
@SerialName("t")
|
val path: String? = null,
|
||||||
val type: String? = null,
|
|
||||||
@SerialName("w")
|
|
||||||
val width: Long? = null,
|
val width: Long? = null,
|
||||||
@SerialName("h")
|
|
||||||
val height: Long? = null,
|
val height: Long? = null,
|
||||||
|
val thumbnail: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -188,15 +180,16 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
|
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
|
||||||
|
if (nhConfig == null) getNhConfig()
|
||||||
val metadata = fetchOrLoadMetadata(manga.id()) {
|
val metadata = fetchOrLoadMetadata(manga.id()) {
|
||||||
client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
|
client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
|
||||||
}
|
}
|
||||||
return PagePreviewPage(
|
return PagePreviewPage(
|
||||||
page,
|
page,
|
||||||
metadata.pageImageTypes.mapIndexed { index, s ->
|
metadata.pageImagePreviewUrls.mapIndexed { index, path ->
|
||||||
PagePreviewInfo(
|
PagePreviewInfo(
|
||||||
index + 1,
|
index + 1,
|
||||||
imageUrl = thumbnailUrlFromType(metadata.mediaId!!, metadata.mediaServer ?: 1, index + 1, s)!!,
|
imageUrl = "$thumbServer/$path",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
@@ -204,15 +197,24 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun thumbnailUrlFromType(
|
var nhConfig: JsonConfig? = null
|
||||||
mediaId: String,
|
suspend fun getNhConfig() {
|
||||||
mediaServer: Int,
|
try {
|
||||||
page: Int,
|
val response =
|
||||||
t: String,
|
withIOContext { client.newCall(GET("https://nhentai.net/api/v2/config", headers)).awaitSuccess() }
|
||||||
) = NHentaiSearchMetadata.typeToExtension(t)?.let {
|
val body = response.body.string()
|
||||||
"https://t$mediaServer.nhentai.net/galleries/$mediaId/${page}t.$it"
|
nhConfig = jsonParser.decodeFromString<JsonConfig>(body)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
nhConfig = JsonConfig(
|
||||||
|
(1..4).map { n -> "https://i$n.nhentai.net" },
|
||||||
|
(1..4).map { n -> "https://t$n.nhentai.net" },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val thumbServer
|
||||||
|
get() = nhConfig?.thumbServers?.random()
|
||||||
|
|
||||||
override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
|
override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
|
||||||
return client.newCachelessCallWithProgress(
|
return client.newCachelessCallWithProgress(
|
||||||
if (cacheControl != null) {
|
if (cacheControl != null) {
|
||||||
@@ -230,10 +232,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
|
|||||||
private val jsonParser = Json {
|
private val jsonParser = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private val GALLERY_JSON_REGEX = Regex(".parse\\(\"(.*)\"\\);")
|
|
||||||
private val MEDIA_SERVER_REGEX = Regex("media_server\\s*:\\s*(\\d+)")
|
|
||||||
private val UNICODE_ESCAPE_REGEX = Regex("\\\\u([0-9a-fA-F]{4})")
|
|
||||||
private const val TITLE_PREF = "Display manga title as:"
|
private const val TITLE_PREF = "Display manga title as:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-63
@@ -50,79 +50,46 @@ class ExtensionsScreenModel(
|
|||||||
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
|
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val queryFilter: (String) -> ((Extension) -> Boolean) = { query ->
|
|
||||||
filter@{ extension ->
|
|
||||||
if (query.isEmpty()) return@filter true
|
|
||||||
query.split(",").any { _input ->
|
|
||||||
val input = _input.trim()
|
|
||||||
if (input.isEmpty()) return@any false
|
|
||||||
when (extension) {
|
|
||||||
is Extension.Available -> {
|
|
||||||
extension.sources.any {
|
|
||||||
it.name.contains(input, ignoreCase = true) ||
|
|
||||||
it.baseUrl.contains(input, ignoreCase = true) ||
|
|
||||||
it.id == input.toLongOrNull()
|
|
||||||
} ||
|
|
||||||
extension.name.contains(input, ignoreCase = true)
|
|
||||||
}
|
|
||||||
is Extension.Installed -> {
|
|
||||||
extension.sources.any {
|
|
||||||
it.name.contains(input, ignoreCase = true) ||
|
|
||||||
it.id == input.toLongOrNull() ||
|
|
||||||
if (it is HttpSource) {
|
|
||||||
it.baseUrl.contains(input, ignoreCase = true)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ||
|
|
||||||
extension.name.contains(input, ignoreCase = true)
|
|
||||||
}
|
|
||||||
is Extension.Untrusted -> extension.name.contains(input, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
screenModelScope.launchIO {
|
screenModelScope.launchIO {
|
||||||
combine(
|
combine(
|
||||||
state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS),
|
state.map { it.searchQuery }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(SEARCH_DEBOUNCE_MILLIS)
|
||||||
|
.map { searchQueryPredicate(it ?: "") },
|
||||||
currentDownloads,
|
currentDownloads,
|
||||||
getExtensions.subscribe(),
|
getExtensions.subscribe(),
|
||||||
) { query, downloads, (_updates, _installed, _available, _untrusted) ->
|
) { predicate, downloads, (_updates, _installed, _available, _untrusted) ->
|
||||||
val searchQuery = query ?: ""
|
buildMap {
|
||||||
|
val updates = _updates.filter(predicate).map(extensionMapper(downloads))
|
||||||
val itemsGroups: ItemGroups = mutableMapOf()
|
if (updates.isNotEmpty()) {
|
||||||
|
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
|
||||||
val updates = _updates.filter(queryFilter(searchQuery)).map(extensionMapper(downloads))
|
|
||||||
if (updates.isNotEmpty()) {
|
|
||||||
itemsGroups[ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending)] = updates
|
|
||||||
}
|
|
||||||
|
|
||||||
val installed = _installed.filter(queryFilter(searchQuery)).map(extensionMapper(downloads))
|
|
||||||
val untrusted = _untrusted.filter(queryFilter(searchQuery)).map(extensionMapper(downloads))
|
|
||||||
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
|
|
||||||
itemsGroups[ExtensionUiModel.Header.Resource(MR.strings.ext_installed)] = installed + untrusted
|
|
||||||
}
|
|
||||||
|
|
||||||
val languagesWithExtensions = _available
|
|
||||||
.filter(queryFilter(searchQuery))
|
|
||||||
.groupBy { it.lang }
|
|
||||||
.toSortedMap(LocaleHelper.comparator)
|
|
||||||
.map { (lang, exts) ->
|
|
||||||
ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to
|
|
||||||
exts.map(extensionMapper(downloads))
|
|
||||||
}
|
}
|
||||||
if (languagesWithExtensions.isNotEmpty()) {
|
|
||||||
itemsGroups.putAll(languagesWithExtensions)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsGroups
|
val installed = _installed.filter(predicate).map(extensionMapper(downloads))
|
||||||
|
val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads))
|
||||||
|
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
|
||||||
|
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted)
|
||||||
|
}
|
||||||
|
|
||||||
|
val languagesWithExtensions = _available
|
||||||
|
.filter(predicate)
|
||||||
|
.groupBy { it.lang }
|
||||||
|
.toSortedMap(LocaleHelper.comparator)
|
||||||
|
.map { (lang, exts) ->
|
||||||
|
ExtensionUiModel.Header.Text(LocaleHelper.getSourceDisplayName(lang, context)) to
|
||||||
|
exts.map(extensionMapper(downloads))
|
||||||
|
}
|
||||||
|
if (languagesWithExtensions.isNotEmpty()) {
|
||||||
|
putAll(languagesWithExtensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.collectLatest {
|
.collectLatest { items ->
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items = it,
|
items = items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +106,36 @@ class ExtensionsScreenModel(
|
|||||||
.launchIn(screenModelScope)
|
.launchIn(screenModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun searchQueryPredicate(query: String): (Extension) -> Boolean {
|
||||||
|
val subqueries = query.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filterNot { it.isBlank() }
|
||||||
|
|
||||||
|
if (subqueries.isEmpty()) return { true }
|
||||||
|
|
||||||
|
return { extension ->
|
||||||
|
subqueries.any { subquery ->
|
||||||
|
if (extension.name.contains(subquery, ignoreCase = true)) return@any true
|
||||||
|
|
||||||
|
when (extension) {
|
||||||
|
is Extension.Installed -> extension.sources.any { source ->
|
||||||
|
source.name.contains(subquery, ignoreCase = true) ||
|
||||||
|
(source as? HttpSource)?.baseUrl?.contains(subquery, ignoreCase = true) == true ||
|
||||||
|
source.id == subquery.toLongOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
is Extension.Available -> extension.sources.any {
|
||||||
|
it.name.contains(subquery, ignoreCase = true) ||
|
||||||
|
it.baseUrl.contains(subquery, ignoreCase = true) ||
|
||||||
|
it.id == subquery.toLongOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun search(query: String?) {
|
fun search(query: String?) {
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
it.copy(searchQuery = query)
|
it.copy(searchQuery = query)
|
||||||
@@ -222,7 +219,7 @@ class ExtensionsScreenModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias ItemGroups = MutableMap<ExtensionUiModel.Header, List<ExtensionUiModel.Item>>
|
typealias ItemGroups = Map<ExtensionUiModel.Header, List<ExtensionUiModel.Item>>
|
||||||
|
|
||||||
object ExtensionUiModel {
|
object ExtensionUiModel {
|
||||||
sealed interface Header {
|
sealed interface Header {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.ui.browse.extension
|
package eu.kanade.tachiyomi.ui.browse.extension
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -49,6 +50,10 @@ fun extensionsTab(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
content = { contentPadding, _ ->
|
content = { contentPadding, _ ->
|
||||||
|
BackHandler(enabled = state.searchQuery != null) {
|
||||||
|
extensionsScreenModel.search(null)
|
||||||
|
}
|
||||||
|
|
||||||
ExtensionScreen(
|
ExtensionScreen(
|
||||||
state = state,
|
state = state,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
|
|||||||
+3
-2
@@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.fragment.app.FragmentContainerView
|
import androidx.fragment.app.FragmentContainerView
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
@@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
|||||||
|
|
||||||
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
||||||
return SourcePreferencesFragment().apply {
|
return SourcePreferencesFragment().apply {
|
||||||
arguments = bundleOf(SOURCE_ID to sourceId)
|
arguments = Bundle().apply {
|
||||||
|
putLong(SOURCE_ID, sourceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-15
@@ -9,11 +9,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.animateFloatingActionButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
@@ -29,7 +32,6 @@ import mihon.feature.migration.config.MigrationConfigScreen
|
|||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
@@ -75,20 +77,22 @@ data class MigrateMangaScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (state.selectionMode) {
|
SmallExtendedFloatingActionButton(
|
||||||
ExtendedFloatingActionButton(
|
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
|
||||||
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
|
icon = {
|
||||||
icon = {
|
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
|
||||||
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
|
},
|
||||||
},
|
onClick = {
|
||||||
onClick = {
|
val selection = state.selection
|
||||||
val selection = state.selection
|
screenModel.clearSelection()
|
||||||
screenModel.clearSelection()
|
navigator.push(MigrationConfigScreen(selection))
|
||||||
navigator.push(MigrationConfigScreen(selection))
|
},
|
||||||
},
|
expanded = lazyListState.shouldExpandFAB(),
|
||||||
expanded = lazyListState.shouldExpandFAB(),
|
modifier = Modifier.animateFloatingActionButton(
|
||||||
)
|
visible = state.selectionMode,
|
||||||
}
|
alignment = Alignment.BottomEnd,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
if (state.isEmpty) {
|
if (state.isEmpty) {
|
||||||
|
|||||||
+13
-9
@@ -1,17 +1,20 @@
|
|||||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.FilterList
|
import androidx.compose.material.icons.outlined.FilterList
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.animateFloatingActionButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
@@ -35,7 +38,6 @@ import mihon.presentation.core.util.collectAsLazyPagingItems
|
|||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
@@ -74,13 +76,15 @@ data class MigrateSourceSearchScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(visible = state.filters.isNotEmpty()) {
|
SmallExtendedFloatingActionButton(
|
||||||
ExtendedFloatingActionButton(
|
text = { Text(text = stringResource(MR.strings.action_filter)) },
|
||||||
text = { Text(text = stringResource(MR.strings.action_filter)) },
|
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
|
||||||
icon = { Icon(Icons.Outlined.FilterList, contentDescription = null) },
|
onClick = screenModel::openFilterSheet,
|
||||||
onClick = screenModel::openFilterSheet,
|
modifier = Modifier.animateFloatingActionButton(
|
||||||
)
|
visible = state.filters.isNotEmpty(),
|
||||||
}
|
alignment = Alignment.BottomEnd,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.ui.download
|
package eu.kanade.tachiyomi.ui.download
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -16,8 +13,10 @@ import androidx.compose.material.icons.outlined.Pause
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.animateFloatingActionButton
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -56,7 +55,6 @@ import kotlinx.collections.immutable.persistentListOf
|
|||||||
import tachiyomi.core.common.util.lang.launchUI
|
import tachiyomi.core.common.util.lang.launchUI
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.Pill
|
import tachiyomi.presentation.core.components.Pill
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
@@ -201,39 +199,37 @@ object DownloadQueueScreen : Screen() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
val isRunning by screenModel.isDownloaderRunning.collectAsState()
|
||||||
visible = downloadList.isNotEmpty(),
|
SmallExtendedFloatingActionButton(
|
||||||
enter = fadeIn(),
|
text = {
|
||||||
exit = fadeOut(),
|
val id = if (isRunning) {
|
||||||
) {
|
MR.strings.action_pause
|
||||||
val isRunning by screenModel.isDownloaderRunning.collectAsState()
|
} else {
|
||||||
ExtendedFloatingActionButton(
|
MR.strings.action_resume
|
||||||
text = {
|
}
|
||||||
val id = if (isRunning) {
|
Text(text = stringResource(id))
|
||||||
MR.strings.action_pause
|
},
|
||||||
} else {
|
icon = {
|
||||||
MR.strings.action_resume
|
val icon = if (isRunning) {
|
||||||
}
|
Icons.Outlined.Pause
|
||||||
Text(text = stringResource(id))
|
} else {
|
||||||
},
|
Icons.Filled.PlayArrow
|
||||||
icon = {
|
}
|
||||||
val icon = if (isRunning) {
|
Icon(imageVector = icon, contentDescription = null)
|
||||||
Icons.Outlined.Pause
|
},
|
||||||
} else {
|
onClick = {
|
||||||
Icons.Filled.PlayArrow
|
if (isRunning) {
|
||||||
}
|
screenModel.pauseDownloads()
|
||||||
Icon(imageVector = icon, contentDescription = null)
|
} else {
|
||||||
},
|
screenModel.startDownloads()
|
||||||
onClick = {
|
}
|
||||||
if (isRunning) {
|
},
|
||||||
screenModel.pauseDownloads()
|
expanded = fabExpanded,
|
||||||
} else {
|
modifier = Modifier.animateFloatingActionButton(
|
||||||
screenModel.startDownloads()
|
visible = downloadList.isNotEmpty(),
|
||||||
}
|
alignment = Alignment.BottomEnd,
|
||||||
},
|
),
|
||||||
expanded = fabExpanded,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
if (downloadList.isEmpty()) {
|
if (downloadList.isEmpty()) {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import tachiyomi.core.common.util.lang.launchNonCancellable
|
|||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
|
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
@@ -122,6 +123,7 @@ class LibraryScreenModel(
|
|||||||
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
||||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||||
|
private val getBookmarkedChaptersByMangaId: GetBookmarkedChaptersByMangaId = Injekt.get(),
|
||||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||||
private val updateManga: UpdateManga = Injekt.get(),
|
private val updateManga: UpdateManga = Injekt.get(),
|
||||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||||
@@ -404,9 +406,7 @@ class LibraryScreenModel(
|
|||||||
val filterFnTracking: (LibraryItem) -> Boolean = tracking@{ item ->
|
val filterFnTracking: (LibraryItem) -> Boolean = tracking@{ item ->
|
||||||
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
|
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
|
||||||
|
|
||||||
val mangaTracks = trackMap
|
val mangaTracks = trackMap[item.id].orEmpty().map { it.trackerId }
|
||||||
.mapValues { entry -> entry.value.map { it.trackerId } }[item.id]
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
|
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
|
||||||
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks }
|
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks }
|
||||||
@@ -736,15 +736,19 @@ class LibraryScreenModel(
|
|||||||
* Queues the amount specified of unread chapters from the list of selected manga
|
* Queues the amount specified of unread chapters from the list of selected manga
|
||||||
*/
|
*/
|
||||||
fun performDownloadAction(action: DownloadAction) {
|
fun performDownloadAction(action: DownloadAction) {
|
||||||
val mangas = state.value.selectedManga
|
when (action) {
|
||||||
val amount = when (action) {
|
DownloadAction.NEXT_1_CHAPTER -> downloadNextChapters(1)
|
||||||
DownloadAction.NEXT_1_CHAPTER -> 1
|
DownloadAction.NEXT_5_CHAPTERS -> downloadNextChapters(5)
|
||||||
DownloadAction.NEXT_5_CHAPTERS -> 5
|
DownloadAction.NEXT_10_CHAPTERS -> downloadNextChapters(10)
|
||||||
DownloadAction.NEXT_10_CHAPTERS -> 10
|
DownloadAction.NEXT_25_CHAPTERS -> downloadNextChapters(25)
|
||||||
DownloadAction.NEXT_25_CHAPTERS -> 25
|
DownloadAction.UNREAD_CHAPTERS -> downloadNextChapters(null)
|
||||||
DownloadAction.UNREAD_CHAPTERS -> null
|
DownloadAction.BOOKMARKED_CHAPTERS -> downloadBookmarkedChapters()
|
||||||
}
|
}
|
||||||
clearSelection()
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadNextChapters(amount: Int?) {
|
||||||
|
val mangas = state.value.selectedManga
|
||||||
screenModelScope.launchNonCancellable {
|
screenModelScope.launchNonCancellable {
|
||||||
mangas.forEach { manga ->
|
mangas.forEach { manga ->
|
||||||
// SY -->
|
// SY -->
|
||||||
@@ -794,6 +798,54 @@ class LibraryScreenModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun downloadBookmarkedChapters() {
|
||||||
|
val mangas = state.value.selectedManga
|
||||||
|
screenModelScope.launchNonCancellable {
|
||||||
|
mangas.forEach { manga ->
|
||||||
|
// SY -->
|
||||||
|
if (manga.source == MERGED_SOURCE_ID) {
|
||||||
|
val mergedMangas = getMergedMangaById.await(manga.id)
|
||||||
|
.associateBy { it.id }
|
||||||
|
getBookmarkedChaptersByMangaId.await(manga.id)
|
||||||
|
.groupBy { it.mangaId }
|
||||||
|
.forEach ab@{ (mangaId, chapters) ->
|
||||||
|
val mergedManga = mergedMangas[mangaId] ?: return@ab
|
||||||
|
val downloadChapters = chapters.fastFilterNot { chapter ->
|
||||||
|
downloadManager.queueState.value.fastAny { chapter.id == it.chapter.id } ||
|
||||||
|
downloadManager.isChapterDownloaded(
|
||||||
|
chapter.name,
|
||||||
|
chapter.scanlator,
|
||||||
|
chapter.url,
|
||||||
|
mergedManga.ogTitle,
|
||||||
|
mergedManga.source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadManager.downloadChapters(mergedManga, downloadChapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
// SY <--
|
||||||
|
|
||||||
|
val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
|
||||||
|
.fastFilterNot { chapter ->
|
||||||
|
downloadManager.getQueuedDownloadOrNull(chapter.id) != null ||
|
||||||
|
downloadManager.isChapterDownloaded(
|
||||||
|
chapter.name,
|
||||||
|
chapter.scanlator,
|
||||||
|
chapter.url,
|
||||||
|
// SY -->
|
||||||
|
manga.ogTitle,
|
||||||
|
// SY <--
|
||||||
|
manga.source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
downloadManager.downloadChapters(manga, chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
fun cleanTitles() {
|
fun cleanTitles() {
|
||||||
state.value.selectedManga.fastFilter {
|
state.value.selectedManga.fastFilter {
|
||||||
|
|||||||
@@ -163,13 +163,6 @@ class MainActivity : BaseActivity() {
|
|||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val didMigration = if (isLaunch) {
|
|
||||||
addAnalytics()
|
|
||||||
Migrator.awaitAndRelease()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
|
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
|
||||||
if (!isTaskRoot) {
|
if (!isTaskRoot) {
|
||||||
finish()
|
finish()
|
||||||
@@ -177,11 +170,17 @@ class MainActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
@Suppress("KotlinConstantConditions")
|
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
|
||||||
val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest")
|
val hasDebugOverlay = (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "releaseTest")
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
setComposeContent {
|
setComposeContent {
|
||||||
|
var didMigration by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
addAnalytics()
|
||||||
|
didMigration = Migrator.awaitAndRelease()
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var incognito by remember { mutableStateOf(getIncognitoState.await(null)) }
|
var incognito by remember { mutableStateOf(getIncognitoState.await(null)) }
|
||||||
@@ -309,7 +308,7 @@ class MainActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
var showChangelog by remember { mutableStateOf(didMigration && !BuildConfig.DEBUG) }
|
var showChangelog by remember { mutableStateOf(didMigration == true && !BuildConfig.DEBUG) }
|
||||||
if (showChangelog) {
|
if (showChangelog) {
|
||||||
// SY -->
|
// SY -->
|
||||||
WhatsNewDialog(onDismissRequest = { showChangelog = false })
|
WhatsNewDialog(onDismissRequest = { showChangelog = false })
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.ui.manga
|
package eu.kanade.tachiyomi.ui.manga
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
@@ -208,7 +207,9 @@ class MangaScreen(
|
|||||||
previewsRowCount = successState.previewsRowCount,
|
previewsRowCount = successState.previewsRowCount,
|
||||||
onMigrateClicked = {
|
onMigrateClicked = {
|
||||||
navigator.push(MigrationConfigScreen(successState.manga.id))
|
navigator.push(MigrationConfigScreen(successState.manga.id))
|
||||||
}.takeIf { successState.manga.favorite },
|
}.takeIf {
|
||||||
|
successState.manga.favorite /* SY --> */ && successState.manga.source != MERGED_SOURCE_ID /* SY <-- */
|
||||||
|
},
|
||||||
onEditNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) },
|
onEditNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) },
|
||||||
// SY -->
|
// SY -->
|
||||||
onMetadataViewerClicked = { openMetadataViewer(navigator, successState.manga) },
|
onMetadataViewerClicked = { openMetadataViewer(navigator, successState.manga) },
|
||||||
@@ -403,12 +404,7 @@ class MangaScreen(
|
|||||||
try {
|
try {
|
||||||
getMangaUrl(manga_, source_)?.let { url ->
|
getMangaUrl(manga_, source_)?.let { url ->
|
||||||
val intent = url.toUri().toShareIntent(context, type = "text/plain")
|
val intent = url.toUri().toShareIntent(context, type = "text/plain")
|
||||||
context.startActivity(
|
context.startActivity(intent)
|
||||||
Intent.createChooser(
|
|
||||||
intent,
|
|
||||||
context.stringResource(MR.strings.action_share),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
context.toast(e.message)
|
context.toast(e.message)
|
||||||
|
|||||||
@@ -1175,6 +1175,13 @@ class MangaScreenModel(
|
|||||||
return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted
|
return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getBookmarkedChapters(): List<Chapter> {
|
||||||
|
val chapterItems = if (skipFiltered) filteredChapters.orEmpty() else allChapters.orEmpty()
|
||||||
|
return chapterItems
|
||||||
|
.filter { (chapter, dlStatus) -> chapter.bookmark && dlStatus == Download.State.NOT_DOWNLOADED }
|
||||||
|
.map { it.chapter }
|
||||||
|
}
|
||||||
|
|
||||||
private fun startDownload(
|
private fun startDownload(
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
startNow: Boolean,
|
startNow: Boolean,
|
||||||
@@ -1237,6 +1244,7 @@ class MangaScreenModel(
|
|||||||
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
|
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
|
||||||
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
|
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
|
||||||
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
|
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
|
||||||
|
DownloadAction.BOOKMARKED_CHAPTERS -> getBookmarkedChapters()
|
||||||
}
|
}
|
||||||
if (chaptersToDownload.isNotEmpty()) {
|
if (chaptersToDownload.isNotEmpty()) {
|
||||||
startDownload(chaptersToDownload, false)
|
startDownload(chaptersToDownload, false)
|
||||||
@@ -1487,7 +1495,6 @@ class MangaScreenModel(
|
|||||||
fun toggleSelection(
|
fun toggleSelection(
|
||||||
item: ChapterList.Item,
|
item: ChapterList.Item,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
userSelected: Boolean = false,
|
|
||||||
fromLongPress: Boolean = false,
|
fromLongPress: Boolean = false,
|
||||||
) {
|
) {
|
||||||
updateSuccessState { successState ->
|
updateSuccessState { successState ->
|
||||||
@@ -1502,7 +1509,7 @@ class MangaScreenModel(
|
|||||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||||
selectedChapterIds.addOrRemove(item.id, selected)
|
selectedChapterIds.addOrRemove(item.id, selected)
|
||||||
|
|
||||||
if (selected && userSelected && fromLongPress) {
|
if (selected && fromLongPress) {
|
||||||
if (firstSelection) {
|
if (firstSelection) {
|
||||||
selectedPositions[0] = selectedIndex
|
selectedPositions[0] = selectedIndex
|
||||||
selectedPositions[1] = selectedIndex
|
selectedPositions[1] = selectedIndex
|
||||||
@@ -1528,7 +1535,7 @@ class MangaScreenModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (userSelected && !fromLongPress) {
|
} else if (!fromLongPress) {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (selectedIndex == selectedPositions[0]) {
|
if (selectedIndex == selectedPositions[0]) {
|
||||||
selectedPositions[0] = indexOfFirst { it.selected }
|
selectedPositions[0] = indexOfFirst { it.selected }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -78,6 +79,7 @@ import eu.kanade.tachiyomi.ui.main.MainActivity
|
|||||||
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
|
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
|
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
|
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.chapter.ReaderChapterItem
|
||||||
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
|
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
@@ -101,6 +103,8 @@ import exh.source.isEhBasedSource
|
|||||||
import exh.ui.ifSourcesLoaded
|
import exh.ui.ifSourcesLoaded
|
||||||
import exh.util.defaultReaderType
|
import exh.util.defaultReaderType
|
||||||
import exh.util.mangaType
|
import exh.util.mangaType
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
@@ -121,6 +125,7 @@ import tachiyomi.core.common.i18n.pluralStringResource
|
|||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import tachiyomi.core.common.util.lang.launchIO
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
import tachiyomi.core.common.util.lang.launchNonCancellable
|
||||||
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
import tachiyomi.core.common.util.lang.withUIContext
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
@@ -394,28 +399,36 @@ class ReaderActivity : BaseActivity() {
|
|||||||
|
|
||||||
is ReaderViewModel.Dialog.ChapterList -> {
|
is ReaderViewModel.Dialog.ChapterList -> {
|
||||||
var chapters by remember {
|
var chapters by remember {
|
||||||
mutableStateOf(viewModel.getChapters().toImmutableList())
|
mutableStateOf<ImmutableList<ReaderChapterItem>?>(null)
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.dialog) {
|
||||||
|
withIOContext {
|
||||||
|
chapters = viewModel.getChapters().toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapters != null) {
|
||||||
|
ChapterListDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
screenModel = settingsScreenModel,
|
||||||
|
chapters = chapters ?: persistentListOf(),
|
||||||
|
onClickChapter = {
|
||||||
|
viewModel.loadNewChapterFromDialog(it)
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
onBookmark = { chapter ->
|
||||||
|
viewModel.toggleBookmark(chapter.id, !chapter.bookmark)
|
||||||
|
chapters = chapters?.map {
|
||||||
|
if (it.chapter.id == chapter.id) {
|
||||||
|
it.copy(chapter = chapter.copy(bookmark = !chapter.bookmark))
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}?.toImmutableList()
|
||||||
|
},
|
||||||
|
state.dateRelativeTime,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
ChapterListDialog(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
screenModel = settingsScreenModel,
|
|
||||||
chapters = chapters,
|
|
||||||
onClickChapter = {
|
|
||||||
viewModel.loadNewChapterFromDialog(it)
|
|
||||||
onDismissRequest()
|
|
||||||
},
|
|
||||||
onBookmark = { chapter ->
|
|
||||||
viewModel.toggleBookmark(chapter.id, !chapter.bookmark)
|
|
||||||
chapters = chapters.map {
|
|
||||||
if (it.chapter.id == chapter.id) {
|
|
||||||
it.copy(chapter = chapter.copy(bookmark = !chapter.bookmark))
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}.toImmutableList()
|
|
||||||
},
|
|
||||||
state.dateRelativeTime,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// SY -->
|
// SY -->
|
||||||
ReaderViewModel.Dialog.AutoScrollHelp -> AlertDialog(
|
ReaderViewModel.Dialog.AutoScrollHelp -> AlertDialog(
|
||||||
@@ -591,8 +604,9 @@ class ReaderActivity : BaseActivity() {
|
|||||||
} else {
|
} else {
|
||||||
cropBorderContinuousVertical
|
cropBorderContinuousVertical
|
||||||
}
|
}
|
||||||
val readerBottomButtons by readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
|
val readerBottomButtons by remember {
|
||||||
.collectAsState(persistentSetOf())
|
readerPreferences.readerBottomButtons().changes().map { it.toImmutableSet() }
|
||||||
|
}.collectAsState(persistentSetOf())
|
||||||
val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState()
|
val dualPageSplitPaged by readerPreferences.dualPageSplitPaged().collectAsState()
|
||||||
|
|
||||||
val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState()
|
val forceHorizontalSeekbar by readerPreferences.forceHorizontalSeekbar().collectAsState()
|
||||||
@@ -934,7 +948,7 @@ class ReaderActivity : BaseActivity() {
|
|||||||
private fun shareChapter() {
|
private fun shareChapter() {
|
||||||
assistUrl?.let {
|
assistUrl?.let {
|
||||||
val intent = it.toUri().toShareIntent(this, type = "text/plain")
|
val intent = it.toUri().toShareIntent(this, type = "text/plain")
|
||||||
startActivity(Intent.createChooser(intent, stringResource(MR.strings.action_share)))
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1139,7 +1153,7 @@ class ReaderActivity : BaseActivity() {
|
|||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
message = /* SY --> */ text, // SY <--
|
message = /* SY --> */ text, // SY <--
|
||||||
)
|
)
|
||||||
startActivity(Intent.createChooser(intent, stringResource(MR.strings.action_share)))
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCopyImageResult(uri: Uri) {
|
private fun onCopyImageResult(uri: Uri) {
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ import exh.source.isEhBasedManga
|
|||||||
import exh.util.defaultReaderType
|
import exh.util.defaultReaderType
|
||||||
import exh.util.mangaType
|
import exh.util.mangaType
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -183,19 +182,26 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
|
|
||||||
private var chapterToDownload: Download? = null
|
private var chapterToDownload: Download? = null
|
||||||
|
|
||||||
private val unfilteredChapterList by lazy {
|
private var unfilteredChapterListCache: List<tachiyomi.domain.chapter.model.Chapter>? = null
|
||||||
val manga = manga!!
|
private suspend fun getUnfilteredChapterList(): List<tachiyomi.domain.chapter.model.Chapter> {
|
||||||
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) }
|
if (unfilteredChapterListCache == null) {
|
||||||
|
val manga = manga!!
|
||||||
|
unfilteredChapterListCache = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false)
|
||||||
|
}
|
||||||
|
return unfilteredChapterListCache!!
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
|
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
|
||||||
* time in a background thread to avoid blocking the UI.
|
* time in a background thread to avoid blocking the UI.
|
||||||
*/
|
*/
|
||||||
private val chapterList by lazy {
|
private var chapterListCache: List<ReaderChapter>? = null
|
||||||
|
private suspend fun getChapterList(): List<ReaderChapter> {
|
||||||
|
chapterListCache?.let { return it }
|
||||||
|
|
||||||
val manga = manga!!
|
val manga = manga!!
|
||||||
// SY -->
|
// SY -->
|
||||||
val (chapters, mangaMap) = runBlocking {
|
val (chapters, mangaMap) =
|
||||||
if (manga.source == MERGED_SOURCE_ID) {
|
if (manga.source == MERGED_SOURCE_ID) {
|
||||||
getMergedChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to
|
getMergedChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to
|
||||||
getMergedMangaById.await(manga.id)
|
getMergedMangaById.await(manga.id)
|
||||||
@@ -203,7 +209,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
} else {
|
} else {
|
||||||
getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to null
|
getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) to null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
fun isChapterDownloaded(chapter: Chapter): Boolean {
|
fun isChapterDownloaded(chapter: Chapter): Boolean {
|
||||||
val chapterManga = mangaMap?.get(chapter.mangaId) ?: manga
|
val chapterManga = mangaMap?.get(chapter.mangaId) ?: manga
|
||||||
return downloadManager.isChapterDownloaded(
|
return downloadManager.isChapterDownloaded(
|
||||||
@@ -253,7 +259,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
else -> chapters
|
else -> chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
chaptersForReader
|
val result = chaptersForReader
|
||||||
.sortedWith(getChapterSort(manga, sortDescending = false))
|
.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||||
.run {
|
.run {
|
||||||
if (readerPreferences.skipDupe().get()) {
|
if (readerPreferences.skipDupe().get()) {
|
||||||
@@ -271,6 +277,8 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
.map { it.toDbChapter() }
|
.map { it.toDbChapter() }
|
||||||
.map(::ReaderChapter)
|
.map(::ReaderChapter)
|
||||||
|
chapterListCache = result
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) }
|
private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) }
|
||||||
@@ -409,7 +417,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
|
|
||||||
loadChapter(
|
loadChapter(
|
||||||
loader!!,
|
loader!!,
|
||||||
chapterList.first { chapterId == it.chapter.id },
|
getChapterList().first { chapterId == it.chapter.id },
|
||||||
/* SY --> */page, /* SY <-- */
|
/* SY --> */page, /* SY <-- */
|
||||||
)
|
)
|
||||||
Result.success(true)
|
Result.success(true)
|
||||||
@@ -427,10 +435,10 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
fun getChapters(): List<ReaderChapterItem> {
|
suspend fun getChapters(): List<ReaderChapterItem> {
|
||||||
val currentChapter = getCurrentChapter()
|
val currentChapter = getCurrentChapter()
|
||||||
|
|
||||||
return chapterList.map {
|
return getChapterList().map {
|
||||||
ReaderChapterItem(
|
ReaderChapterItem(
|
||||||
chapter = it.chapter.toDomainChapter()!!,
|
chapter = it.chapter.toDomainChapter()!!,
|
||||||
manga = manga!!,
|
manga = manga!!,
|
||||||
@@ -454,6 +462,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
): ViewerChapters {
|
): ViewerChapters {
|
||||||
loader.loadChapter(chapter /* SY --> */, page/* SY <-- */)
|
loader.loadChapter(chapter /* SY --> */, page/* SY <-- */)
|
||||||
|
|
||||||
|
val chapterList = getChapterList()
|
||||||
val chapterPos = chapterList.indexOf(chapter)
|
val chapterPos = chapterList.indexOf(chapter)
|
||||||
val newChapters = ViewerChapters(
|
val newChapters = ViewerChapters(
|
||||||
chapter,
|
chapter,
|
||||||
@@ -503,7 +512,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun loadNewChapterFromDialog(chapter: Chapter) {
|
fun loadNewChapterFromDialog(chapter: Chapter) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val newChapter = chapterList.firstOrNull { it.chapter.id == chapter.id } ?: return@launchIO
|
val newChapter = getChapterList().firstOrNull { it.chapter.id == chapter.id } ?: return@launchIO
|
||||||
loadAdjacent(newChapter)
|
loadAdjacent(newChapter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,7 +664,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
* if setting is enabled and [currentChapter] is queued for download
|
* if setting is enabled and [currentChapter] is queued for download
|
||||||
*/
|
*/
|
||||||
private fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? {
|
private fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? {
|
||||||
return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id!!.toLong())?.also {
|
return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id!!)?.also {
|
||||||
downloadManager.cancelQueuedDownloads(listOf(it))
|
downloadManager.cancelQueuedDownloads(listOf(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,11 +674,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
* If both conditions are satisfied enqueues chapter for delete
|
* If both conditions are satisfied enqueues chapter for delete
|
||||||
* @param currentChapter current chapter, which is going to be marked as read.
|
* @param currentChapter current chapter, which is going to be marked as read.
|
||||||
*/
|
*/
|
||||||
private fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
|
private suspend fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
|
||||||
val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots().get()
|
val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots().get()
|
||||||
if (removeAfterReadSlots == -1) return
|
if (removeAfterReadSlots == -1) return
|
||||||
|
|
||||||
// Determine which chapter should be deleted and enqueue
|
// Determine which chapter should be deleted and enqueue
|
||||||
|
val chapterList = getChapterList()
|
||||||
val currentChapterPosition = chapterList.indexOf(currentChapter)
|
val currentChapterPosition = chapterList.indexOf(currentChapter)
|
||||||
val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots)
|
val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots)
|
||||||
|
|
||||||
@@ -739,7 +749,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
// SY -->
|
// SY -->
|
||||||
if (manga?.isEhBasedManga() == true) {
|
if (manga?.isEhBasedManga() == true) {
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launchNonCancellable {
|
||||||
val chapterUpdates = unfilteredChapterList
|
val chapterUpdates = getUnfilteredChapterList()
|
||||||
.filter { it.sourceOrder > readerChapter.chapter.source_order }
|
.filter { it.sourceOrder > readerChapter.chapter.source_order }
|
||||||
.map { chapter ->
|
.map { chapter ->
|
||||||
ChapterUpdate(
|
ChapterUpdate(
|
||||||
@@ -759,7 +769,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
||||||
if (!markDuplicateAsRead) return
|
if (!markDuplicateAsRead) return
|
||||||
|
|
||||||
val duplicateUnreadChapters = unfilteredChapterList
|
val duplicateUnreadChapters = getUnfilteredChapterList()
|
||||||
.mapNotNull { chapter ->
|
.mapNotNull { chapter ->
|
||||||
if (
|
if (
|
||||||
!chapter.read &&
|
!chapter.read &&
|
||||||
@@ -774,7 +784,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
updateChapter.awaitAll(duplicateUnreadChapters)
|
updateChapter.awaitAll(duplicateUnreadChapters)
|
||||||
// SY -->
|
// SY -->
|
||||||
duplicateUnreadChapters.forEach { chapterUpdate ->
|
duplicateUnreadChapters.forEach { chapterUpdate ->
|
||||||
val chapter = unfilteredChapterList.first { it.id == chapterUpdate.id }
|
val chapter = getUnfilteredChapterList().first { it.id == chapterUpdate.id }
|
||||||
deleteChapterIfNeeded(ReaderChapter(chapter))
|
deleteChapterIfNeeded(ReaderChapter(chapter))
|
||||||
}
|
}
|
||||||
// SY <--
|
// SY <--
|
||||||
@@ -848,7 +858,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launchNonCancellable {
|
||||||
updateChapter.await(
|
updateChapter.await(
|
||||||
ChapterUpdate(
|
ChapterUpdate(
|
||||||
id = chapter.id!!.toLong(),
|
id = chapter.id!!,
|
||||||
bookmark = bookmarked,
|
bookmark = bookmarked,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -863,9 +873,9 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
fun toggleBookmark(chapterId: Long, bookmarked: Boolean) {
|
fun toggleBookmark(chapterId: Long, bookmarked: Boolean) {
|
||||||
val chapter = chapterList.find { it.chapter.id == chapterId }?.chapter ?: return
|
|
||||||
chapter.bookmark = bookmarked
|
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launchNonCancellable {
|
||||||
|
val chapter = getChapterList().find { it.chapter.id == chapterId }?.chapter ?: return@launchNonCancellable
|
||||||
|
chapter.bookmark = bookmarked
|
||||||
updateChapter.await(
|
updateChapter.await(
|
||||||
ChapterUpdate(
|
ChapterUpdate(
|
||||||
id = chapterId,
|
id = chapterId,
|
||||||
@@ -900,7 +910,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||||||
*/
|
*/
|
||||||
fun setMangaReadingMode(readingMode: ReadingMode) {
|
fun setMangaReadingMode(readingMode: ReadingMode) {
|
||||||
val manga = manga ?: return
|
val manga = manga ?: return
|
||||||
runBlocking(Dispatchers.IO) {
|
viewModelScope.launchIO {
|
||||||
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
|
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
|
||||||
val currChapters = state.value.viewerChapters
|
val currChapters = state.value.viewerChapters
|
||||||
if (currChapters != null) {
|
if (currChapters != null) {
|
||||||
|
|||||||
@@ -69,15 +69,8 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
|||||||
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
|
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
|
||||||
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
||||||
|
|
||||||
// Add previous chapter pages and transition.
|
// Add previous chapter pages and transition
|
||||||
if (chapters.prevChapter != null) {
|
chapters.prevChapter?.pages?.let(newItems::addAll)
|
||||||
// We only need to add the last few pages of the previous chapter, because it'll be
|
|
||||||
// selected as the current chapter when one of those pages is selected.
|
|
||||||
val prevPages = chapters.prevChapter.pages
|
|
||||||
if (prevPages != null) {
|
|
||||||
newItems.addAll(prevPages.takeLast(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip transition page if the chapter is loaded & current page is not a transition page
|
// Skip transition page if the chapter is loaded & current page is not a transition page
|
||||||
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
||||||
@@ -119,14 +112,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chapters.nextChapter != null) {
|
chapters.nextChapter?.pages?.let(newItems::addAll)
|
||||||
// Add at most two pages, because this chapter will be selected before the user can
|
|
||||||
// swap more pages.
|
|
||||||
val nextPages = chapters.nextChapter.pages
|
|
||||||
if (nextPages != null) {
|
|
||||||
newItems.addAll(nextPages.take(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resets double-page splits, else insert pages get misplaced
|
// Resets double-page splits, else insert pages get misplaced
|
||||||
subItems.filterIsInstance<InsertPage>().also { subItems.removeAll(it) }
|
subItems.filterIsInstance<InsertPage>().also { subItems.removeAll(it) }
|
||||||
|
|||||||
@@ -43,14 +43,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
|
|||||||
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
|
||||||
|
|
||||||
// Add previous chapter pages and transition.
|
// Add previous chapter pages and transition.
|
||||||
if (chapters.prevChapter != null) {
|
chapters.prevChapter?.pages?.let(newItems::addAll)
|
||||||
// We only need to add the last few pages of the previous chapter, because it'll be
|
|
||||||
// selected as the current chapter when one of those pages is selected.
|
|
||||||
val prevPages = chapters.prevChapter.pages
|
|
||||||
if (prevPages != null) {
|
|
||||||
newItems.addAll(prevPages.takeLast(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip transition page if the chapter is loaded & current page is not a transition page
|
// Skip transition page if the chapter is loaded & current page is not a transition page
|
||||||
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
|
||||||
@@ -70,14 +63,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
|
|||||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chapters.nextChapter != null) {
|
chapters.nextChapter?.pages?.let(newItems::addAll)
|
||||||
// Add at most two pages, because this chapter will be selected before the user can
|
|
||||||
// swap more pages.
|
|
||||||
val nextPages = chapters.nextChapter.pages
|
|
||||||
if (nextPages != null) {
|
|
||||||
newItems.addAll(nextPages.take(2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItems(newItems)
|
updateItems(newItems)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.Application
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.util.fastFilter
|
||||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
import cafe.adriel.voyager.core.model.screenModelScope
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
import eu.kanade.core.preference.asState
|
import eu.kanade.core.preference.asState
|
||||||
@@ -30,11 +31,16 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import tachiyomi.core.common.util.lang.launchIO
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
import tachiyomi.core.common.util.lang.launchNonCancellable
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
@@ -43,9 +49,11 @@ import tachiyomi.domain.chapter.interactor.UpdateChapter
|
|||||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
|
import tachiyomi.domain.manga.model.applyFilter
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
import tachiyomi.domain.updates.interactor.GetUpdates
|
import tachiyomi.domain.updates.interactor.GetUpdates
|
||||||
import tachiyomi.domain.updates.model.UpdatesWithRelations
|
import tachiyomi.domain.updates.model.UpdatesWithRelations
|
||||||
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
@@ -60,6 +68,7 @@ class UpdatesScreenModel(
|
|||||||
private val getManga: GetManga = Injekt.get(),
|
private val getManga: GetManga = Injekt.get(),
|
||||||
private val getChapter: GetChapter = Injekt.get(),
|
private val getChapter: GetChapter = Injekt.get(),
|
||||||
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||||
|
private val updatesPreferences: UpdatesPreferences = Injekt.get(),
|
||||||
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
||||||
// SY -->
|
// SY -->
|
||||||
readerPreferences: ReaderPreferences = Injekt.get(),
|
readerPreferences: ReaderPreferences = Injekt.get(),
|
||||||
@@ -85,19 +94,35 @@ class UpdatesScreenModel(
|
|||||||
val limit = ZonedDateTime.now().minusMonths(3).toInstant()
|
val limit = ZonedDateTime.now().minusMonths(3).toInstant()
|
||||||
|
|
||||||
combine(
|
combine(
|
||||||
getUpdates.subscribe(limit).distinctUntilChanged(),
|
// needed for SQL filters (unread, started, bookmarked, etc)
|
||||||
|
getUpdatesItemPreferenceFlow()
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMapLatest {
|
||||||
|
getUpdates.subscribe(
|
||||||
|
limit,
|
||||||
|
unread = it.filterUnread.toBooleanOrNull(),
|
||||||
|
started = it.filterStarted.toBooleanOrNull(),
|
||||||
|
bookmarked = it.filterBookmarked.toBooleanOrNull(),
|
||||||
|
hideExcludedScanlators = it.filterExcludedScanlators,
|
||||||
|
).distinctUntilChanged()
|
||||||
|
},
|
||||||
downloadCache.changes,
|
downloadCache.changes,
|
||||||
downloadManager.queueState,
|
downloadManager.queueState,
|
||||||
) { updates, _, _ -> updates }
|
// needed for Kotlin filters (downloaded)
|
||||||
.catch {
|
getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new ->
|
||||||
logcat(LogPriority.ERROR, it)
|
old.filterDownloaded == new.filterDownloaded
|
||||||
_events.send(Event.InternalError)
|
},
|
||||||
}
|
) { updates, _, _, itemPreferences ->
|
||||||
.collectLatest { updates ->
|
updates
|
||||||
|
.toUpdateItems()
|
||||||
|
.applyFilters(itemPreferences)
|
||||||
|
.toPersistentList()
|
||||||
|
}
|
||||||
|
.collectLatest { updateItems ->
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items = updates.toUpdateItems(),
|
items = updateItems,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,9 +133,43 @@ class UpdatesScreenModel(
|
|||||||
.catch { logcat(LogPriority.ERROR, it) }
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
.collect(this@UpdatesScreenModel::updateDownloadState)
|
.collect(this@UpdatesScreenModel::updateDownloadState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUpdatesItemPreferenceFlow()
|
||||||
|
.map { prefs ->
|
||||||
|
listOf(
|
||||||
|
prefs.filterUnread,
|
||||||
|
prefs.filterDownloaded,
|
||||||
|
prefs.filterStarted,
|
||||||
|
prefs.filterBookmarked,
|
||||||
|
)
|
||||||
|
.any { it != TriState.DISABLED }
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach {
|
||||||
|
mutableState.update { state ->
|
||||||
|
state.copy(hasActiveFilters = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(screenModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<UpdatesWithRelations>.toUpdateItems(): PersistentList<UpdatesItem> {
|
private fun List<UpdatesItem>.applyFilters(
|
||||||
|
preferences: ItemPreferences,
|
||||||
|
): List<UpdatesItem> {
|
||||||
|
val filterDownloaded = preferences.filterDownloaded
|
||||||
|
|
||||||
|
val filterFnDownloaded: (UpdatesItem) -> Boolean = {
|
||||||
|
applyFilter(filterDownloaded) {
|
||||||
|
it.downloadStateProvider() == Download.State.DOWNLOADED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fastFilter {
|
||||||
|
filterFnDownloaded(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<UpdatesWithRelations>.toUpdateItems(): List<UpdatesItem> {
|
||||||
return this
|
return this
|
||||||
.map { update ->
|
.map { update ->
|
||||||
val activeDownload = downloadManager.getQueuedDownloadOrNull(update.chapterId)
|
val activeDownload = downloadManager.getQueuedDownloadOrNull(update.chapterId)
|
||||||
@@ -135,7 +194,6 @@ class UpdatesScreenModel(
|
|||||||
selected = update.chapterId in selectedChapterIds,
|
selected = update.chapterId in selectedChapterIds,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toPersistentList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibrary(): Boolean {
|
fun updateLibrary(): Boolean {
|
||||||
@@ -193,7 +251,7 @@ class UpdatesScreenModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadingNow(chapterId: Long) {
|
private suspend fun startDownloadingNow(chapterId: Long) {
|
||||||
downloadManager.startDownloadNow(chapterId)
|
downloadManager.startDownloadNow(chapterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +337,6 @@ class UpdatesScreenModel(
|
|||||||
fun toggleSelection(
|
fun toggleSelection(
|
||||||
item: UpdatesItem,
|
item: UpdatesItem,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
userSelected: Boolean = false,
|
|
||||||
fromLongPress: Boolean = false,
|
fromLongPress: Boolean = false,
|
||||||
) {
|
) {
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
@@ -294,7 +351,7 @@ class UpdatesScreenModel(
|
|||||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||||
selectedChapterIds.addOrRemove(item.update.chapterId, selected)
|
selectedChapterIds.addOrRemove(item.update.chapterId, selected)
|
||||||
|
|
||||||
if (selected && userSelected && fromLongPress) {
|
if (selected && fromLongPress) {
|
||||||
if (firstSelection) {
|
if (firstSelection) {
|
||||||
selectedPositions[0] = selectedIndex
|
selectedPositions[0] = selectedIndex
|
||||||
selectedPositions[1] = selectedIndex
|
selectedPositions[1] = selectedIndex
|
||||||
@@ -320,7 +377,7 @@ class UpdatesScreenModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (userSelected && !fromLongPress) {
|
} else if (!fromLongPress) {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (selectedIndex == selectedPositions[0]) {
|
if (selectedIndex == selectedPositions[0]) {
|
||||||
selectedPositions[0] = indexOfFirst { it.selected }
|
selectedPositions[0] = indexOfFirst { it.selected }
|
||||||
@@ -373,9 +430,41 @@ class UpdatesScreenModel(
|
|||||||
libraryPreferences.newUpdatesCount().set(0)
|
libraryPreferences.newUpdatesCount().set(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getUpdatesItemPreferenceFlow(): Flow<ItemPreferences> {
|
||||||
|
return combine(
|
||||||
|
updatesPreferences.filterDownloaded().changes(),
|
||||||
|
updatesPreferences.filterUnread().changes(),
|
||||||
|
updatesPreferences.filterStarted().changes(),
|
||||||
|
updatesPreferences.filterBookmarked().changes(),
|
||||||
|
updatesPreferences.filterExcludedScanlators().changes(),
|
||||||
|
) { downloaded, unread, started, bookmarked, excludedScanlators ->
|
||||||
|
ItemPreferences(
|
||||||
|
filterDownloaded = downloaded,
|
||||||
|
filterUnread = unread,
|
||||||
|
filterStarted = started,
|
||||||
|
filterBookmarked = bookmarked,
|
||||||
|
filterExcludedScanlators = excludedScanlators,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFilterDialog() {
|
||||||
|
mutableState.update { it.copy(dialog = Dialog.FilterSheet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
private data class ItemPreferences(
|
||||||
|
val filterDownloaded: TriState,
|
||||||
|
val filterUnread: TriState,
|
||||||
|
val filterStarted: TriState,
|
||||||
|
val filterBookmarked: TriState,
|
||||||
|
val filterExcludedScanlators: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class State(
|
data class State(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
|
val hasActiveFilters: Boolean = false,
|
||||||
val items: PersistentList<UpdatesItem> = persistentListOf(),
|
val items: PersistentList<UpdatesItem> = persistentListOf(),
|
||||||
val dialog: Dialog? = null,
|
val dialog: Dialog? = null,
|
||||||
) {
|
) {
|
||||||
@@ -399,6 +488,7 @@ class UpdatesScreenModel(
|
|||||||
|
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog
|
data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog
|
||||||
|
data object FilterSheet : Dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
@@ -407,6 +497,14 @@ class UpdatesScreenModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun TriState.toBooleanOrNull(): Boolean? {
|
||||||
|
return when (this) {
|
||||||
|
TriState.DISABLED -> null
|
||||||
|
TriState.ENABLED_IS -> true
|
||||||
|
TriState.ENABLED_NOT -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class UpdatesItem(
|
data class UpdatesItem(
|
||||||
val update: UpdatesWithRelations,
|
val update: UpdatesWithRelations,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.updates
|
||||||
|
|
||||||
|
import cafe.adriel.voyager.core.model.ScreenModel
|
||||||
|
import tachiyomi.core.common.preference.Preference
|
||||||
|
import tachiyomi.core.common.preference.TriState
|
||||||
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class UpdatesSettingsScreenModel(
|
||||||
|
val updatesPreferences: UpdatesPreferences = Injekt.get(),
|
||||||
|
) : ScreenModel {
|
||||||
|
|
||||||
|
fun toggleFilter(preference: (UpdatesPreferences) -> Preference<TriState>) {
|
||||||
|
preference(updatesPreferences).getAndSet {
|
||||||
|
it.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import eu.kanade.core.preference.asState
|
|||||||
import eu.kanade.domain.ui.UiPreferences
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
import eu.kanade.presentation.updates.UpdateScreen
|
import eu.kanade.presentation.updates.UpdateScreen
|
||||||
import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog
|
import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog
|
||||||
|
import eu.kanade.presentation.updates.UpdatesFilterDialog
|
||||||
import eu.kanade.presentation.util.Tab
|
import eu.kanade.presentation.util.Tab
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen
|
import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen
|
||||||
@@ -70,6 +71,7 @@ data object UpdatesTab : Tab {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
val screenModel = rememberScreenModel { UpdatesScreenModel() }
|
val screenModel = rememberScreenModel { UpdatesScreenModel() }
|
||||||
|
val settingsScreenModel = rememberScreenModel { UpdatesSettingsScreenModel() }
|
||||||
val state by screenModel.state.collectAsState()
|
val state by screenModel.state.collectAsState()
|
||||||
|
|
||||||
UpdateScreen(
|
UpdateScreen(
|
||||||
@@ -93,6 +95,8 @@ data object UpdatesTab : Tab {
|
|||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
onCalendarClicked = { navigator.push(UpcomingScreen()) },
|
onCalendarClicked = { navigator.push(UpcomingScreen()) },
|
||||||
|
onFilterClicked = screenModel::showFilterDialog,
|
||||||
|
hasActiveFilters = state.hasActiveFilters,
|
||||||
)
|
)
|
||||||
|
|
||||||
val onDismissDialog = { screenModel.setDialog(null) }
|
val onDismissDialog = { screenModel.setDialog(null) }
|
||||||
@@ -103,6 +107,12 @@ data object UpdatesTab : Tab {
|
|||||||
onConfirm = { screenModel.deleteChapters(dialog.toDelete) },
|
onConfirm = { screenModel.deleteChapters(dialog.toDelete) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is UpdatesScreenModel.Dialog.FilterSheet -> {
|
||||||
|
UpdatesFilterDialog(
|
||||||
|
onDismissRequest = onDismissDialog,
|
||||||
|
screenModel = settingsScreenModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
@@ -20,11 +21,12 @@ import java.time.ZoneId
|
|||||||
class CrashLogUtil(
|
class CrashLogUtil(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val extensionManager: ExtensionManager = Injekt.get(),
|
private val extensionManager: ExtensionManager = Injekt.get(),
|
||||||
|
private val preferences: BasePreferences = Injekt.get(),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
|
suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext {
|
||||||
try {
|
try {
|
||||||
val file = context.createFileInCacheDir("tachiyomi_crash_logs.txt")
|
val file = context.createFileInCacheDir("tachiyomi_sy_crash_logs.txt")
|
||||||
|
|
||||||
file.appendText(getDebugInfo() + "\n\n")
|
file.appendText(getDebugInfo() + "\n\n")
|
||||||
getExtensionsInfo()?.let { file.appendText("$it\n\n") }
|
getExtensionsInfo()?.let { file.appendText("$it\n\n") }
|
||||||
@@ -44,6 +46,7 @@ class CrashLogUtil(
|
|||||||
App ID: ${BuildConfig.APPLICATION_ID}
|
App ID: ${BuildConfig.APPLICATION_ID}
|
||||||
App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME})
|
App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME})
|
||||||
Preview build: $syDebugVersion
|
Preview build: $syDebugVersion
|
||||||
|
Installation ID: ${preferences.installationId().get()}
|
||||||
Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY})
|
Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY})
|
||||||
Device brand: ${Build.BRAND}
|
Device brand: ${Build.BRAND}
|
||||||
Device manufacturer: ${Build.MANUFACTURER}
|
Device manufacturer: ${Build.MANUFACTURER}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package androidx.preference
|
|||||||
/**
|
/**
|
||||||
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
|
* Returns package-private [EditTextPreference.getOnBindEditTextListener]
|
||||||
*/
|
*/
|
||||||
|
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
|
||||||
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
|
fun EditTextPreference.getOnBindEditTextListener(): EditTextPreference.OnBindEditTextListener? {
|
||||||
return onBindEditTextListener
|
return onBindEditTextListener
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package exh.ui.login
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
@@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.core.net.toUri
|
||||||
import eu.kanade.presentation.webview.EhLoginWebViewScreen
|
import eu.kanade.presentation.webview.EhLoginWebViewScreen
|
||||||
import eu.kanade.presentation.webview.components.IgneousDialog
|
import eu.kanade.presentation.webview.components.IgneousDialog
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
@@ -92,16 +92,32 @@ class EhLoginActivity : BaseActivity() {
|
|||||||
|
|
||||||
private fun onPageFinished(view: WebView, url: String, customIgneous: String?) {
|
private fun onPageFinished(view: WebView, url: String, customIgneous: String?) {
|
||||||
xLogD(url)
|
xLogD(url)
|
||||||
val parsedUrl = Uri.parse(url)
|
val parsedUrl = url.toUri()
|
||||||
if (parsedUrl.host.equals("forums.e-hentai.org", ignoreCase = true)) {
|
if (parsedUrl.host.equals("forums.e-hentai.org", ignoreCase = true)) {
|
||||||
// Hide distracting content
|
view.evaluateJavascript(
|
||||||
if (!parsedUrl.queryParameterNames.contains(PARAM_SKIP_INJECT)) {
|
"""
|
||||||
view.evaluateJavascript(HIDE_JS, null)
|
(function() {
|
||||||
}
|
let html = document.documentElement.innerHTML;
|
||||||
// Check login result
|
return html.includes("/cdn-cgi/");
|
||||||
|
})();
|
||||||
|
""".trimIndent(),
|
||||||
|
) { result ->
|
||||||
|
val isCloudflareBlock = result == "true"
|
||||||
|
|
||||||
if (parsedUrl.getQueryParameter("code")?.toInt() != 0) {
|
if (isCloudflareBlock) {
|
||||||
if (checkLoginCookies(url)) view.loadUrl("https://exhentai.org/")
|
xLogD("Cloudflare block detected — skipping logic")
|
||||||
|
return@evaluateJavascript
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide distracting content
|
||||||
|
if (!parsedUrl.queryParameterNames.contains(PARAM_SKIP_INJECT)) {
|
||||||
|
view.evaluateJavascript(HIDE_JS, null)
|
||||||
|
}
|
||||||
|
// Check login result
|
||||||
|
|
||||||
|
if (parsedUrl.getQueryParameter("code")?.toInt() != 0) {
|
||||||
|
if (checkLoginCookies(url)) view.loadUrl("https://exhentai.org/")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (parsedUrl.host.equals("exhentai.org", ignoreCase = true)) {
|
} else if (parsedUrl.host.equals("exhentai.org", ignoreCase = true)) {
|
||||||
// At ExHentai, check that everything worked out...
|
// At ExHentai, check that everything worked out...
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ fun NHentaiDescription(state: State.Success, openMetadataViewer: () -> Unit) {
|
|||||||
|
|
||||||
binding.pages.text = context.pluralStringResource(
|
binding.pages.text = context.pluralStringResource(
|
||||||
SYMR.plurals.num_pages,
|
SYMR.plurals.num_pages,
|
||||||
meta.pageImageTypes.size,
|
meta.pageImagePreviewUrls.size,
|
||||||
meta.pageImageTypes.size,
|
meta.pageImagePreviewUrls.size,
|
||||||
)
|
)
|
||||||
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24)
|
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ object Migrator {
|
|||||||
result = null
|
result = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun awaitAndRelease(): Boolean = runBlocking {
|
suspend fun awaitAndRelease(): Boolean {
|
||||||
await().also { release() }
|
return await().also { release() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package mihon.core.migration.migrations
|
||||||
|
|
||||||
|
import eu.kanade.domain.base.BasePreferences
|
||||||
|
import mihon.core.common.FeatureFlags
|
||||||
|
import mihon.core.migration.Migration
|
||||||
|
import mihon.core.migration.MigrationContext
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
|
||||||
|
class InstallationIdMigration : Migration {
|
||||||
|
override val version: Float = Migration.ALWAYS
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
|
||||||
|
val installationId = migrationContext.get<BasePreferences>()?.installationId() ?: return false
|
||||||
|
if (!installationId.isSet()) installationId.set(FeatureFlags.newInstallationId())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,4 +47,5 @@ val migrations: List<Migration>
|
|||||||
TrustExtensionRepositoryMigration(),
|
TrustExtensionRepositoryMigration(),
|
||||||
CategoryPreferencesCleanupMigration(),
|
CategoryPreferencesCleanupMigration(),
|
||||||
RemoveDuplicateReaderPreferenceMigration(),
|
RemoveDuplicateReaderPreferenceMigration(),
|
||||||
|
InstallationIdMigration(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class MoveSortingModeSettingsMigration : Migration {
|
|||||||
categoryId = it.id,
|
categoryId = it.id,
|
||||||
flags = it.flags and 0b00111100L.inv(),
|
flags = it.flags and 0b00111100L.inv(),
|
||||||
name = null,
|
name = null,
|
||||||
|
version = it.version,
|
||||||
|
uid = it.uid,
|
||||||
|
last_modified_at = null,
|
||||||
|
isSyncing = null,
|
||||||
order = null,
|
order = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class MigrateMangaUseCase(
|
|||||||
updatedChapter = updatedChapter.copy(
|
updatedChapter = updatedChapter.copy(
|
||||||
dateFetch = prevChapter.dateFetch,
|
dateFetch = prevChapter.dateFetch,
|
||||||
bookmark = prevChapter.bookmark,
|
bookmark = prevChapter.bookmark,
|
||||||
|
lastPageRead = prevChapter.lastPageRead,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SmallExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -62,7 +63,6 @@ import tachiyomi.domain.source.service.SourceManager
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||||
import tachiyomi.presentation.core.components.Pill
|
import tachiyomi.presentation.core.components.Pill
|
||||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
@@ -144,7 +144,7 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
SmallExtendedFloatingActionButton(
|
||||||
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
|
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
|
||||||
icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) },
|
icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -331,13 +331,13 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSources(save: Boolean = true, action: (List<MigrationSource>) -> List<MigrationSource>) {
|
private fun updateSources(action: (List<MigrationSource>) -> List<MigrationSource>) {
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
val updatedSources = action(state.sources)
|
val updatedSources = action(state.sources)
|
||||||
val includedSources = updatedSources.mapNotNull { if (!it.isSelected) null else it.id }
|
val includedSources = updatedSources.mapNotNull { if (!it.isSelected) null else it.id }
|
||||||
state.copy(sources = updatedSources.sortedWith(sourcesComparator(includedSources)))
|
state.copy(sources = updatedSources.sortedWith(sourcesComparator(includedSources)))
|
||||||
}
|
}
|
||||||
if (save) saveSources()
|
saveSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initSources() {
|
private fun initSources() {
|
||||||
@@ -370,7 +370,9 @@ class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
|
|||||||
}
|
}
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
updateSources(save = false) { sources }
|
mutableState.update { state ->
|
||||||
|
state.copy(sources = sources.sortedWith(sourcesComparator(includedSources)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSelection(id: Long) {
|
fun toggleSelection(id: Long) {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ private class MigrateDialogScreenModel(
|
|||||||
}
|
}
|
||||||
val selectedFlags = sourcePreference.migrationFlags().get()
|
val selectedFlags = sourcePreference.migrationFlags().get()
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
it.copy(
|
State(
|
||||||
current = current,
|
current = current,
|
||||||
target = target,
|
target = target,
|
||||||
applicableFlags = applicableFlags,
|
applicableFlags = applicableFlags,
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ fun CalenderHeader(
|
|||||||
}
|
}
|
||||||
Row {
|
Row {
|
||||||
IconButton(onClick = onPreviousClick) {
|
IconButton(onClick = onPreviousClick) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev))
|
Icon(Icons.Default.KeyboardArrowLeft, stringResource(MR.strings.upcoming_calendar_prev))
|
||||||
}
|
}
|
||||||
IconButton(onClick = onNextClick) {
|
IconButton(onClick = onNextClick) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next))
|
Icon(Icons.Default.KeyboardArrowRight, stringResource(MR.strings.upcoming_calendar_next))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import org.gradle.kotlin.dsl.provideDelegate
|
|||||||
import org.gradle.kotlin.dsl.the
|
import org.gradle.kotlin.dsl.the
|
||||||
import org.gradle.kotlin.dsl.withType
|
import org.gradle.kotlin.dsl.withType
|
||||||
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
|
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
|
||||||
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
|
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ internal fun Project.configureAndroid(commonExtension: CommonExtension<*, *, *,
|
|||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(AndroidConfig.JvmTarget)
|
jvmTarget.set(AndroidConfig.JvmTarget)
|
||||||
freeCompilerArgs.addAll(
|
freeCompilerArgs.addAll(
|
||||||
"-Xcontext-receivers",
|
"-Xcontext-parameters",
|
||||||
"-opt-in=kotlin.RequiresOptIn",
|
"-opt-in=kotlin.RequiresOptIn",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,8 +72,6 @@ internal fun Project.configureCompose(commonExtension: CommonExtension<*, *, *,
|
|||||||
}
|
}
|
||||||
|
|
||||||
extensions.configure<ComposeCompilerGradlePluginExtension> {
|
extensions.configure<ComposeCompilerGradlePluginExtension> {
|
||||||
featureFlags.set(setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups))
|
|
||||||
|
|
||||||
val enableMetrics = project.providers.gradleProperty("enableComposeCompilerMetrics").orNull.toBoolean()
|
val enableMetrics = project.providers.gradleProperty("enableComposeCompilerMetrics").orNull.toBoolean()
|
||||||
val enableReports = project.providers.gradleProperty("enableComposeCompilerReports").orNull.toBoolean()
|
val enableReports = project.providers.gradleProperty("enableComposeCompilerReports").orNull.toBoolean()
|
||||||
|
|
||||||
|
|||||||
@@ -134,18 +134,18 @@ fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: Progre
|
|||||||
return progressClient.newCall(request)
|
return progressClient.newCall(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
context(Json)
|
context(_: Json)
|
||||||
inline fun <reified T> Response.parseAs(): T {
|
inline fun <reified T> Response.parseAs(): T {
|
||||||
return decodeFromJsonResponse(serializer(), this)
|
return decodeFromJsonResponse(serializer(), this)
|
||||||
}
|
}
|
||||||
|
|
||||||
context(Json)
|
context(json: Json)
|
||||||
fun <T> decodeFromJsonResponse(
|
fun <T> decodeFromJsonResponse(
|
||||||
deserializer: DeserializationStrategy<T>,
|
deserializer: DeserializationStrategy<T>,
|
||||||
response: Response,
|
response: Response,
|
||||||
): T {
|
): T {
|
||||||
return response.body.source().use {
|
return response.body.source().use {
|
||||||
decodeFromBufferedSource(deserializer, it)
|
json.decodeFromBufferedSource(deserializer, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -73,7 +73,7 @@ class CloudflareInterceptor(
|
|||||||
executor.execute {
|
executor.execute {
|
||||||
webview = createWebView(originalRequest)
|
webview = createWebView(originalRequest)
|
||||||
|
|
||||||
webview?.webViewClient = object : WebViewClientCompat() {
|
webview.webViewClient = object : WebViewClientCompat() {
|
||||||
override fun onPageFinished(view: WebView, url: String) {
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
fun isCloudFlareBypassed(): Boolean {
|
fun isCloudFlareBypassed(): Boolean {
|
||||||
return cookieManager.get(origRequestUrl.toHttpUrl())
|
return cookieManager.get(origRequestUrl.toHttpUrl())
|
||||||
@@ -111,7 +111,7 @@ class CloudflareInterceptor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
webview?.loadUrl(origRequestUrl, headers)
|
webview.loadUrl(origRequestUrl, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
latch.awaitFor30Seconds()
|
latch.awaitFor30Seconds()
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package eu.kanade.tachiyomi.util.system
|
package eu.kanade.tachiyomi.util.system
|
||||||
|
|
||||||
import android.annotation.TargetApi
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebResourceResponse
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
@Suppress("OverridingDeprecatedMember")
|
@Suppress("OverridingDeprecatedMember")
|
||||||
abstract class WebViewClientCompat : WebViewClient() {
|
abstract class WebViewClientCompat : WebViewClient() {
|
||||||
@@ -28,7 +28,7 @@ abstract class WebViewClientCompat : WebViewClient() {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.N)
|
@RequiresApi(Build.VERSION_CODES.N)
|
||||||
final override fun shouldOverrideUrlLoading(
|
final override fun shouldOverrideUrlLoading(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
request: WebResourceRequest,
|
request: WebResourceRequest,
|
||||||
@@ -36,6 +36,7 @@ abstract class WebViewClientCompat : WebViewClient() {
|
|||||||
return shouldOverrideUrlCompat(view, request.url.toString())
|
return shouldOverrideUrlCompat(view, request.url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("shouldOverrideUrlLoading(WebView, WebResourceRequest)")
|
||||||
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||||
return shouldOverrideUrlCompat(view, url)
|
return shouldOverrideUrlCompat(view, url)
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ abstract class WebViewClientCompat : WebViewClient() {
|
|||||||
return shouldInterceptRequestCompat(view, request.url.toString())
|
return shouldInterceptRequestCompat(view, request.url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("shouldInterceptRequest(WebView, WebResourceRequest)")
|
||||||
final override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
|
final override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
|
||||||
return shouldInterceptRequestCompat(view, url)
|
return shouldInterceptRequestCompat(view, url)
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,7 @@ abstract class WebViewClientCompat : WebViewClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("onReceivedError(WebView, WebResourceRequest, WebResourceError)")
|
||||||
final override fun onReceivedError(
|
final override fun onReceivedError(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
errorCode: Int,
|
errorCode: Int,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package mihon.core.common
|
||||||
|
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
object FeatureFlags {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun newInstallationId(): String {
|
||||||
|
return Uuid.random().toHexDashString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import me.zhanghai.android.libarchive.ArchiveException
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
import mihon.core.common.archive.ArchiveEntry as MihonArchiveEntry
|
||||||
|
|
||||||
class ArchiveInputStream(
|
class ArchiveInputStream(
|
||||||
buffer: Long,
|
buffer: Long,
|
||||||
@@ -67,18 +68,20 @@ class ArchiveInputStream(
|
|||||||
Archive.readFree(archive)
|
Archive.readFree(archive)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getNextEntry() = Archive.readNextHeader(archive).takeUnless { it == 0L }?.let { entry ->
|
fun getNextEntry(): MihonArchiveEntry? {
|
||||||
val name = ArchiveEntry.pathnameUtf8(entry) ?: ArchiveEntry.pathname(entry)?.decodeToString() ?: return null
|
return Archive.readNextHeader(archive).takeUnless { it == 0L }?.let { entry ->
|
||||||
val isFile = ArchiveEntry.filetype(entry) == ArchiveEntry.AE_IFREG
|
val name = ArchiveEntry.pathnameUtf8(entry) ?: ArchiveEntry.pathname(entry)?.decodeToString() ?: return null
|
||||||
// SY -->
|
val isFile = ArchiveEntry.filetype(entry) == ArchiveEntry.AE_IFREG
|
||||||
val isEncrypted = ArchiveEntry.isEncrypted(entry)
|
|
||||||
// SY <--
|
|
||||||
ArchiveEntry(
|
|
||||||
name,
|
|
||||||
isFile,
|
|
||||||
// SY -->
|
// SY -->
|
||||||
isEncrypted,
|
val isEncrypted = ArchiveEntry.isEncrypted(entry)
|
||||||
// SY <--
|
// SY <--
|
||||||
)
|
MihonArchiveEntry(
|
||||||
|
name,
|
||||||
|
isFile,
|
||||||
|
// SY -->
|
||||||
|
isEncrypted,
|
||||||
|
// SY <--
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user