Merge branch 'master' into sync-part-final

This commit is contained in:
KaiserBh
2024-01-06 16:35:24 +11:00
committed by GitHub
52 changed files with 887 additions and 756 deletions

View File

@@ -22,7 +22,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi"
versionCode = 113
versionCode = 114
versionName = "0.14.7"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View File

@@ -11,8 +11,11 @@ import eu.kanade.domain.manga.interactor.GetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.source.interactor.CreateSourceRepo
import eu.kanade.domain.source.interactor.DeleteSourceRepo
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.GetLanguagesWithSources
import eu.kanade.domain.source.interactor.GetSourceRepos
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.domain.source.interactor.ToggleLanguage
@@ -167,5 +170,9 @@ class DomainModule : InjektModule {
addFactory { ToggleLanguage(get()) }
addFactory { ToggleSource(get()) }
addFactory { ToggleSourcePin(get()) }
addFactory { CreateSourceRepo(get()) }
addFactory { DeleteSourceRepo(get()) }
addFactory { GetSourceRepos(get()) }
}
}

View File

@@ -0,0 +1,26 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.preference.plusAssign
class CreateSourceRepo(private val preferences: SourcePreferences) {
fun await(name: String): Result {
// Do not allow invalid formats
if (!name.matches(repoRegex) || name.startsWith(OFFICIAL_REPO_BASE_URL)) {
return Result.InvalidUrl
}
preferences.extensionRepos() += name.substringBeforeLast("/index.min.json")
return Result.Success
}
sealed interface Result {
data object InvalidUrl : Result
data object Success : Result
}
}
const val OFFICIAL_REPO_BASE_URL = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo"
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()

View File

@@ -0,0 +1,11 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.preference.minusAssign
class DeleteSourceRepo(private val preferences: SourcePreferences) {
fun await(repo: String) {
preferences.extensionRepos() -= repo
}
}

View File

@@ -0,0 +1,13 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetSourceRepos(private val preferences: SourcePreferences) {
fun subscribe(): Flow<List<String>> {
return preferences.extensionRepos().changes()
.map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
}
}

View File

@@ -38,6 +38,8 @@ class SourcePreferences(
SetMigrateSorting.Direction.ASCENDING,
)
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet())

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
@@ -38,6 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -67,7 +67,6 @@ fun ExtensionDetailsScreen(
state: ExtensionDetailsScreenModel.State,
onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickWhatsNew: () -> Unit,
onClickReadme: () -> Unit,
onClickEnableAll: () -> Unit,
onClickDisableAll: () -> Unit,
onClickClearCookies: () -> Unit,
@@ -91,13 +90,6 @@ fun ExtensionDetailsScreen(
onClick = onClickWhatsNew,
),
)
add(
AppBar.Action(
title = stringResource(MR.strings.action_faq_and_guides),
icon = Icons.AutoMirrored.Outlined.HelpOutline,
onClick = onClickReadme,
),
)
}
addAll(
listOf(
@@ -125,7 +117,7 @@ fun ExtensionDetailsScreen(
) { paddingValues ->
if (state.extension == null) {
EmptyScreen(
stringRes = MR.strings.empty_screen,
MR.strings.empty_screen,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
@@ -158,6 +150,21 @@ private fun ExtensionDetails(
contentPadding = contentPadding,
) {
when {
extension.isRepoSource ->
item {
val uriHandler = LocalUriHandler.current
WarningBanner(
MR.strings.repo_extension_message,
modifier = Modifier.clickable {
extension.repoUrl ?: return@clickable
uriHandler.openUri(
extension.repoUrl
.replace("https://raw.githubusercontent.com", "https://github.com")
.removeSuffix("/repo/"),
)
},
)
}
extension.isUnofficial ->
item {
WarningBanner(MR.strings.unofficial_extension_message)

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.focus.focusRequester
import eu.kanade.core.preference.asToggleableState
import eu.kanade.presentation.category.visualName
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import tachiyomi.core.preference.CheckboxState
import tachiyomi.domain.category.model.Category
@@ -40,12 +41,12 @@ import kotlin.time.Duration.Companion.seconds
fun CategoryCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
categories: ImmutableList<Category>,
categories: ImmutableList<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.anyWithName(name) }
val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
@@ -70,10 +71,13 @@ fun CategoryCreateDialog(
},
text = {
OutlinedTextField(
modifier = Modifier.focusRequester(focusRequester),
modifier = Modifier
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = { Text(text = stringResource(MR.strings.name)) },
label = {
Text(text = stringResource(MR.strings.name))
},
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_category_exists
@@ -99,14 +103,14 @@ fun CategoryCreateDialog(
fun CategoryRenameDialog(
onDismissRequest: () -> Unit,
onRename: (String) -> Unit,
categories: ImmutableList<Category>,
category: Category,
categories: ImmutableList<String>,
category: String,
) {
var name by remember { mutableStateOf(category.name) }
var name by remember { mutableStateOf(category) }
var valueHasChanged by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.anyWithName(name) }
val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
@@ -163,7 +167,7 @@ fun CategoryRenameDialog(
fun CategoryDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
category: Category,
category: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
@@ -184,7 +188,7 @@ fun CategoryDeleteDialog(
Text(text = stringResource(MR.strings.delete_category))
},
text = {
Text(text = stringResource(MR.strings.delete_category_confirmation, category.name))
Text(text = stringResource(MR.strings.delete_category_confirmation, category))
},
)
}
@@ -220,7 +224,7 @@ fun CategorySortAlphabeticallyDialog(
@Composable
fun ChangeCategoryDialog(
initialSelection: List<CheckboxState<Category>>,
initialSelection: ImmutableList<CheckboxState<Category>>,
onDismissRequest: () -> Unit,
onEditCategories: () -> Unit,
onConfirm: (List<Long>, List<Long>) -> Unit,
@@ -292,7 +296,7 @@ fun ChangeCategoryDialog(
if (index != -1) {
val mutableList = selection.toMutableList()
mutableList[index] = it.next()
selection = mutableList.toList()
selection = mutableList.toList().toImmutableList()
}
}
Row(
@@ -326,7 +330,3 @@ fun ChangeCategoryDialog(
},
)
}
private fun List<Category>.anyWithName(name: String): Boolean {
return any { name == it.name }
}

View File

@@ -49,7 +49,7 @@ fun CategoryListItem(
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "")
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
Text(
text = category.name,
modifier = Modifier
@@ -61,13 +61,13 @@ fun CategoryListItem(
onClick = { onMoveUp(category) },
enabled = canMoveUp,
) {
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "")
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null)
}
IconButton(
onClick = { onMoveDown(category) },
enabled = canMoveDown,
) {
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "")
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onRename) {

View File

@@ -78,12 +78,13 @@ import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp
import tachiyomi.source.local.isLocal
import java.time.Instant
@Composable
fun MangaScreen(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: Int?,
nextUpdate: Instant?,
isTabletUi: Boolean,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
@@ -138,7 +139,7 @@ fun MangaScreen(
MangaScreenSmallImpl(
state = state,
snackbarHostState = snackbarHostState,
fetchInterval = fetchInterval,
nextUpdate = nextUpdate,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
onBackClicked = onBackClicked,
@@ -175,7 +176,7 @@ fun MangaScreen(
snackbarHostState = snackbarHostState,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
fetchInterval = fetchInterval,
nextUpdate = nextUpdate,
onBackClicked = onBackClicked,
onChapterClicked = onChapterClicked,
onDownloadChapter = onDownloadChapter,
@@ -211,7 +212,7 @@ fun MangaScreen(
private fun MangaScreenSmallImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: Int?,
nextUpdate: Instant?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
onBackClicked: () -> Unit,
@@ -272,10 +273,7 @@ private fun MangaScreenSmallImpl(
onBackClicked()
}
}
BackHandler(
enabled = isAnySelected,
onBack = { onAllChapterSelected(false) },
)
BackHandler(onBack = internalOnBackPressed)
Scaffold(
topBar = {
@@ -402,7 +400,7 @@ private fun MangaScreenSmallImpl(
MangaActionRow(
favorite = state.manga.favorite,
trackingCount = state.trackingCount,
fetchInterval = fetchInterval,
nextUpdate = nextUpdate,
isUserIntervalMode = state.manga.fetchInterval < 0,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
@@ -462,7 +460,7 @@ private fun MangaScreenSmallImpl(
fun MangaScreenLargeImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: Int?,
nextUpdate: Instant?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
onBackClicked: () -> Unit,
@@ -529,10 +527,7 @@ fun MangaScreenLargeImpl(
onBackClicked()
}
}
BackHandler(
enabled = isAnySelected,
onBack = { onAllChapterSelected(false) },
)
BackHandler(onBack = internalOnBackPressed)
Scaffold(
topBar = {
@@ -641,7 +636,7 @@ fun MangaScreenLargeImpl(
MangaActionRow(
favorite = state.manga.favorite,
trackingCount = state.trackingCount,
fetchInterval = fetchInterval,
nextUpdate = nextUpdate,
isUserIntervalMode = state.manga.fetchInterval < 0,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,

View File

@@ -3,10 +3,6 @@ package eu.kanade.presentation.manga.components
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -29,18 +25,15 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -55,13 +48,11 @@ import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.collections.immutable.persistentListOf
import soup.compose.material.motion.MotionConstants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.clickableNoIndication
import kotlin.coroutines.cancellation.CancellationException
@Composable
fun MangaCoverDialog(
@@ -160,32 +151,10 @@ fun MangaCoverDialog(
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
var scale by remember { mutableFloatStateOf(1f) }
PredictiveBackHandler { progress ->
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.8f, LinearOutSlowInEasing.transform(backEvent.progress))
}
onDismissRequest()
} catch (e: CancellationException) {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
) { value, _ ->
scale = value
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.clickableNoIndication(onClick = onDismissRequest)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
.clickableNoIndication(onClick = onDismissRequest),
) {
AndroidView(
factory = {

View File

@@ -2,8 +2,11 @@ package eu.kanade.presentation.manga.components
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -16,10 +19,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.system.isDevFlavor
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import kotlinx.collections.immutable.toImmutableList
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import java.time.Instant
@@ -59,57 +65,71 @@ fun DeleteChaptersDialog(
@Composable
fun SetIntervalDialog(
interval: Int,
nextUpdate: Long,
nextUpdate: Instant?,
onDismissRequest: () -> Unit,
onValueChanged: (Int) -> Unit,
onValueChanged: ((Int) -> Unit)? = null,
) {
var selectedInterval by rememberSaveable { mutableIntStateOf(if (interval < 0) -interval else 0) }
val nextUpdateDays = remember(nextUpdate) {
val now = Instant.now()
val nextUpdateInstant = Instant.ofEpochMilli(nextUpdate)
now.until(nextUpdateInstant, ChronoUnit.DAYS)
return@remember if (nextUpdate != null) {
val now = Instant.now()
now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
} else {
null
}
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(MR.strings.manga_modify_calculated_interval_title)) },
title = { Text(stringResource(MR.strings.pref_library_update_smart_update)) },
text = {
Column {
if (nextUpdateDays >= 0) {
if (nextUpdateDays != null && nextUpdateDays >= 0) {
Text(
stringResource(
MR.strings.manga_interval_expected_update,
pluralStringResource(
MR.plurals.day,
count = nextUpdateDays.toInt(),
count = nextUpdateDays,
nextUpdateDays,
),
pluralStringResource(
MR.plurals.day,
count = interval,
interval,
),
),
)
Spacer(Modifier.height(MaterialTheme.padding.small))
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val size = DpSize(width = maxWidth / 2, height = 128.dp)
val items = (0..FetchInterval.MAX_INTERVAL)
.map {
if (it == 0) {
stringResource(MR.strings.label_default)
} else {
it.toString()
// TODO: selecting "1" then doesn't allow for future changes unless defaulting first?
if (onValueChanged != null && (isDevFlavor || isPreviewBuildType)) {
Text(stringResource(MR.strings.manga_interval_custom_amount))
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val size = DpSize(width = maxWidth / 2, height = 128.dp)
val items = (0..FetchInterval.MAX_INTERVAL)
.map {
if (it == 0) {
stringResource(MR.strings.label_default)
} else {
it.toString()
}
}
}
.toImmutableList()
WheelTextPicker(
items = items,
size = size,
startIndex = selectedInterval,
onSelectionChanged = { selectedInterval = it },
)
.toImmutableList()
WheelTextPicker(
items = items,
size = size,
startIndex = selectedInterval,
onSelectionChanged = { selectedInterval = it },
)
}
}
}
},
@@ -120,7 +140,7 @@ fun SetIntervalDialog(
},
confirmButton = {
TextButton(onClick = {
onValueChanged(selectedInterval)
onValueChanged?.invoke(selectedInterval)
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))

View File

@@ -86,7 +86,8 @@ import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.clickableNoIndication
import tachiyomi.presentation.core.util.secondaryItemAlpha
import kotlin.math.absoluteValue
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@@ -165,7 +166,7 @@ fun MangaInfoBox(
fun MangaActionRow(
favorite: Boolean,
trackingCount: Int,
fetchInterval: Int?,
nextUpdate: Instant?,
isUserIntervalMode: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
@@ -177,6 +178,16 @@ fun MangaActionRow(
) {
val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
// TODO: show something better when using custom interval
val nextUpdateDays = remember(nextUpdate) {
return@remember if (nextUpdate != null) {
val now = Instant.now()
now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
} else {
null
}
}
Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
MangaActionButton(
title = if (favorite) {
@@ -189,18 +200,20 @@ fun MangaActionRow(
onClick = onAddToLibraryClicked,
onLongClick = onEditCategory,
)
if (onEditIntervalClicked != null && fetchInterval != null) {
MangaActionButton(
title = pluralStringResource(
MangaActionButton(
title = if (nextUpdateDays != null) {
pluralStringResource(
MR.plurals.day,
count = fetchInterval.absoluteValue,
fetchInterval.absoluteValue,
),
icon = Icons.Default.HourglassEmpty,
color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onEditIntervalClicked,
)
}
count = nextUpdateDays,
nextUpdateDays,
)
} else {
stringResource(MR.strings.not_applicable)
},
icon = Icons.Default.HourglassEmpty,
color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = { onEditIntervalClicked?.invoke() },
)
MangaActionButton(
title = if (trackingCount == 0) {
stringResource(MR.strings.manga_tracking_tab)

View File

@@ -17,7 +17,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.Material3RichText
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.string.RichTextStringStyle
import eu.kanade.presentation.theme.TachiyomiTheme
import tachiyomi.i18n.MR
@@ -42,7 +42,7 @@ fun NewUpdateScreen(
rejectText = stringResource(MR.strings.action_not_now),
onRejectClick = onRejectUpdate,
) {
Material3RichText(
RichText(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.padding.large),

View File

@@ -2,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.core.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings {
@Composable
override fun getPreferences(): List<Preference> {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val reposCount by sourcePreferences.extensionRepos().collectAsState()
return listOf(
Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources),
@@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings {
pref = sourcePreferences.hideInLibraryItems(),
title = stringResource(MR.strings.pref_hide_in_library_items),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
onClick = {
navigator.push(ExtensionReposScreen())
},
),
),
),
Preference.PreferenceGroup(

View File

@@ -198,7 +198,7 @@ object SettingsLibraryScreen : SearchableSettings {
),
Preference.PreferenceItem.MultiSelectListPreference(
pref = libraryPreferences.autoUpdateMangaRestrictions(),
title = stringResource(MR.strings.pref_library_update_manga_restriction),
title = stringResource(MR.strings.pref_library_update_smart_update),
entries = persistentMapOf(
MANGA_HAS_UNREAD to stringResource(MR.strings.pref_update_only_completely_read),
MANGA_NON_READ to stringResource(MR.strings.pref_update_only_started),

View File

@@ -0,0 +1,69 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.presentation.core.screens.LoadingScreen
class ExtensionReposScreen : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
val state by screenModel.state.collectAsState()
if (state is RepoScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as RepoScreenState.Success
ExtensionReposScreen(
state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
RepoDialog.Create -> {
ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
categories = successState.repos,
)
}
is RepoDialog.Delete -> {
ExtensionRepoDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.repo) },
repo = dialog.repo,
)
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is RepoEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.source.interactor.CreateSourceRepo
import eu.kanade.domain.source.interactor.DeleteSourceRepo
import eu.kanade.domain.source.interactor.GetSourceRepos
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import tachiyomi.core.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel(
private val getSourceRepos: GetSourceRepos = Injekt.get(),
private val createSourceRepo: CreateSourceRepo = Injekt.get(),
private val deleteSourceRepo: DeleteSourceRepo = Injekt.get(),
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
val events = _events.receiveAsFlow()
init {
screenModelScope.launchIO {
getSourceRepos.subscribe()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
repos = repos.toImmutableList(),
)
}
}
}
}
/**
* Creates and adds a new repo to the database.
*
* @param name The name of the repo to create.
*/
fun createRepo(name: String) {
screenModelScope.launchIO {
when (createSourceRepo.await(name)) {
is CreateSourceRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
else -> {}
}
}
}
/**
* Deletes the given repo from the database.
*
* @param repo The repo to delete.
*/
fun deleteRepo(repo: String) {
screenModelScope.launchIO {
deleteSourceRepo.await(repo)
}
}
fun showDialog(dialog: RepoDialog) {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
}
sealed class RepoScreenState {
@Immutable
data object Loading : RepoScreenState()
@Immutable
data class Success(
val repos: ImmutableList<String>,
val dialog: RepoDialog? = null,
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}

View File

@@ -0,0 +1,81 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import kotlinx.collections.immutable.ImmutableList
import tachiyomi.presentation.core.components.material.padding
@Composable
fun ExtensionReposContent(
repos: ImmutableList<String>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onClickDelete: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
state = lazyListState,
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
modifier = modifier,
) {
items(repos) { repo ->
ExtensionRepoListItem(
modifier = Modifier.animateItemPlacement(),
repo = repo,
onDelete = { onClickDelete(repo) },
)
}
}
}
@Composable
private fun ExtensionRepoListItem(
repo: String,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
ElevatedCard(
modifier = modifier,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = MaterialTheme.padding.medium,
top = MaterialTheme.padding.medium,
end = MaterialTheme.padding.medium,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onDelete) {
Icon(imageVector = Icons.Outlined.Delete, contentDescription = null)
}
}
}
}

View File

@@ -0,0 +1,117 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
categories: ImmutableList<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
enabled = name.isNotEmpty() && !nameAlreadyExists,
onClick = {
onCreate(name)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Column {
Text(text = stringResource(MR.strings.action_add_repo_message))
OutlinedTextField(
modifier = Modifier
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = {
Text(text = stringResource(MR.strings.label_add_repo_input))
},
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_repo_exists
} else {
MR.strings.information_required_plain
}
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
singleLine = true,
)
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionRepoDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_delete_repo))
},
text = {
Text(text = stringResource(MR.strings.delete_repo_confirmation, repo))
},
)
}

View File

@@ -0,0 +1,61 @@
@file:JvmName("ExtensionReposScreenKt")
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@Composable
fun ExtensionReposScreen(
state: RepoScreenState.Success,
onClickCreate: () -> Unit,
onClickDelete: (String) -> Unit,
navigateUp: () -> Unit,
) {
val lazyListState = rememberLazyListState()
Scaffold(
topBar = { scrollBehavior ->
AppBar(
navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos),
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CategoryFloatingActionButton(
lazyListState = lazyListState,
onCreate = onClickCreate,
)
},
) { paddingValues ->
if (state.isEmpty) {
EmptyScreen(
MR.strings.information_empty_repos,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
}
ExtensionReposContent(
repos = state.repos,
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onClickDelete = onClickDelete,
)
}
}

View File

@@ -1,54 +1,13 @@
package eu.kanade.presentation.util
import android.annotation.SuppressLint
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.ScreenModelStore
import cafe.adriel.voyager.core.screen.Screen
@@ -57,25 +16,14 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransitionContent
import eu.kanade.tachiyomi.util.view.getWindowRadius
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import soup.compose.material.motion.MotionConstants
import soup.compose.material.motion.animation.materialSharedAxisX
import soup.compose.material.motion.animation.rememberSlideDistance
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.PI
import kotlin.math.sin
/**
* For invoking back press to the parent activity
@@ -109,299 +57,17 @@ interface AssistContentScreen {
}
@Composable
fun DefaultNavigatorScreenTransition(
navigator: Navigator,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val view = LocalView.current
val handler = remember {
OnBackHandler(
scope = scope,
windowCornerRadius = view.getWindowRadius(),
onBackPressed = navigator::pop,
)
}
PredictiveBackHandler(enabled = navigator.canPop) { progress ->
progress
.onStart { handler.reset() }
.onCompletion { e ->
if (e == null) {
handler.onBackConfirmed()
} else {
handler.onBackCancelled()
}
}
.collect(handler::onBackEvent)
}
Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) {
val currentSceneEntry = navigator.lastItem
val showPrev by remember {
derivedStateOf { handler.scale < 1f || handler.translationY != 0f }
}
val visibleItems = remember(currentSceneEntry, showPrev) {
if (showPrev) {
val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2)
listOfNotNull(currentSceneEntry, prevSceneEntry)
} else {
listOfNotNull(currentSceneEntry)
}
}
val slideDistance = rememberSlideDistance()
val screenContent = remember {
movableContentOf<Screen> { screen ->
navigator.saveableState("transition", screen) {
screen.Content()
}
}
}
visibleItems.forEachIndexed { index, backStackEntry ->
val isPrev = index == 1 && visibleItems.size > 1
if (!isPrev) {
AnimatedContent(
targetState = backStackEntry,
transitionSpec = {
val forward = navigator.lastEvent != StackEvent.Pop
if (!forward && !handler.isReady) {
// Pop screen without animation when predictive back is in use
EnterTransition.None togetherWith ExitTransition.None
} else {
materialSharedAxisX(
forward = forward,
slideDistance = slideDistance,
)
}
},
modifier = Modifier
.zIndex(1f)
.graphicsLayer {
this.alpha = handler.alpha
this.transformOrigin = TransformOrigin(
pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f,
pivotFractionY = 0.5f,
)
this.scaleX = handler.scale
this.scaleY = handler.scale
this.translationY = handler.translationY
this.clip = true
this.shape = if (showPrev) {
RoundedCornerShape(handler.windowCornerRadius.toFloat())
} else {
RectangleShape
}
}
.then(
if (showPrev) {
Modifier.pointerInput(Unit) {
// Animated content should not be interactive
}
} else {
Modifier
},
),
content = {
if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) {
// Avoid drawing previous screen
return@AnimatedContent
}
screenContent(it)
},
)
} else {
Box(
modifier = Modifier
.zIndex(0f)
.drawWithCache {
val bounds = Rect(Offset.Zero, size)
val matrix = ColorMatrix().apply {
// Reduce saturation and brightness
setToSaturation(lerp(1f, 0.95f, handler.alpha))
set(0, 4, lerp(0f, -25f, handler.alpha))
set(1, 4, lerp(0f, -25f, handler.alpha))
set(2, 4, lerp(0f, -25f, handler.alpha))
}
val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) }
onDrawWithContent {
drawIntoCanvas {
it.saveLayer(bounds, paint)
drawContent()
it.restore()
}
}
}
.graphicsLayer {
val blurRadius = 5.dp.toPx() * handler.alpha
renderEffect = if (blurRadius > 0f) {
BlurEffect(blurRadius, blurRadius)
} else {
null
}
}
.pointerInput(Unit) {
// bg content should not be interactive
},
content = { screenContent(backStackEntry) },
)
}
}
LaunchedEffect(currentSceneEntry) {
// Reset *after* the screen is popped successfully
// so that the correct transition is applied
handler.setReady()
}
}
}
@Stable
private class OnBackHandler(
private val scope: CoroutineScope,
val windowCornerRadius: Int,
private val onBackPressed: () -> Unit,
) {
var isReady = true
private set
var alpha by mutableFloatStateOf(1f)
private set
var scale by mutableFloatStateOf(1f)
private set
var translationY by mutableFloatStateOf(0f)
private set
var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT)
private set
private var containerSize = Size.Zero
private var startPointY = Float.NaN
var isPredictiveBack by mutableStateOf(false)
private set
private var animationJob: Job? = null
set(value) {
isReady = false
field = value
}
fun updateContainerSize(size: Size) {
containerSize = size
}
fun setReady() {
reset()
animationJob?.cancel()
animationJob = null
isReady = true
isPredictiveBack = false
}
fun reset() {
startPointY = Float.NaN
}
fun onBackEvent(backEvent: BackEventCompat) {
if (!isReady) return
isPredictiveBack = true
swipeEdge = backEvent.swipeEdge
val progress = LinearOutSlowInEasing.transform(backEvent.progress)
scale = lerp(1f, 0.85f, progress)
if (startPointY.isNaN()) {
startPointY = backEvent.touchY
}
val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height
val translateYDistance = containerSize.height / 20
translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress
}
fun onBackConfirmed() {
if (!isReady) return
if (isPredictiveBack) {
// Continue predictive animation and pop the screen
val animationSpec = tween<Float>(
durationMillis = MotionConstants.DefaultMotionDuration,
easing = FastOutSlowInEasing,
fun DefaultNavigatorScreenTransition(navigator: Navigator) {
val slideDistance = rememberSlideDistance()
ScreenTransition(
navigator = navigator,
transition = {
materialSharedAxisX(
forward = navigator.lastEvent != StackEvent.Pop,
slideDistance = slideDistance,
)
animationJob = scope.launch {
try {
listOf(
async {
animate(
initialValue = alpha,
targetValue = 0f,
animationSpec = animationSpec,
) { value, _ ->
alpha = value
}
},
async {
animate(
initialValue = scale,
targetValue = scale - 0.05f,
animationSpec = animationSpec,
) { value, _ ->
scale = value
}
},
).awaitAll()
} catch (e: CancellationException) {
// no-op
} finally {
onBackPressed()
alpha = 1f
translationY = 0f
scale = 1f
}
}
} else {
// Pop right away and use default transition
onBackPressed()
}
}
fun onBackCancelled() {
// Reset states
isPredictiveBack = false
animationJob = scope.launch {
listOf(
async {
animate(
initialValue = scale,
targetValue = 1f,
) { value, _ ->
scale = value
}
},
async {
animate(
initialValue = alpha,
targetValue = 1f,
) { value, _ ->
alpha = value
}
},
async {
animate(
initialValue = translationY,
targetValue = 0f,
) { value, _ ->
translationY = value
}
},
).awaitAll()
isReady = true
}
}
},
)
}
@Composable

View File

@@ -405,6 +405,11 @@ object Migrations {
// Deleting old download cache index files, but might as well clear it all out
context.cacheDir.deleteRecursively()
}
if (oldVersion < 114) {
sourcePreferences.extensionRepos().getAndSet {
it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet()
}
}
return true
}

View File

@@ -28,6 +28,7 @@ import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML
import tachiyomi.core.storage.AndroidStorageFolderProvider
import tachiyomi.core.storage.UniFileTempFileManager
import tachiyomi.data.AndroidDatabaseHandler
import tachiyomi.data.Database
import tachiyomi.data.DatabaseHandler
@@ -112,6 +113,8 @@ class AppModule(val app: Application) : InjektModule {
ProtoBuf
}
addSingletonFactory { UniFileTempFileManager(app) }
addSingletonFactory { ChapterCache(app, get()) }
addSingletonFactory { CoverCache(app) }

View File

@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension
import android.content.Context
import android.graphics.drawable.Drawable
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
@@ -49,7 +49,7 @@ class ExtensionManager(
/**
* API where all the available extensions can be found.
*/
private val api = ExtensionGithubApi()
private val api = ExtensionApi()
/**
* The installer which installs, updates and uninstalls the extensions.
@@ -258,7 +258,6 @@ class ExtensionManager(
val untrustedSignatures = _untrustedExtensionsFlow.value.map { it.signatureHash }.toSet()
if (signature !in untrustedSignatures) return
ExtensionLoader.trustedSignatures += signature
preferences.trustedSignatures() += signature
val nowTrustedExtensions = _untrustedExtensionsFlow.value.filter { it.signatureHash == signature }

View File

@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension.api
import android.content.Context
import eu.kanade.domain.source.interactor.OFFICIAL_REPO_BASE_URL
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
@@ -20,10 +22,11 @@ import uy.kohesive.injekt.injectLazy
import java.time.Instant
import kotlin.time.Duration.Companion.days
internal class ExtensionGithubApi {
internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy()
private val extensionManager: ExtensionManager by injectLazy()
private val json: Json by injectLazy()
@@ -31,39 +34,16 @@ internal class ExtensionGithubApi {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
}
private var requiresFallbackSource = false
suspend fun findExtensions(): List<Extension.Available> {
return withIOContext {
val githubResponse = if (requiresFallbackSource) {
null
} else {
try {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.awaitSuccess()
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
requiresFallbackSource = true
null
}
}
val response = githubResponse ?: run {
networkService.client
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
.awaitSuccess()
}
val extensions = with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions()
val extensions = buildList {
addAll(getExtensions(OFFICIAL_REPO_BASE_URL, true))
sourcePreferences.extensionRepos().get().map { addAll(getExtensions(it, false)) }
}
// Sanity check - a small number of extensions probably means something broke
// with the repo generator
if (extensions.size < 100) {
if (extensions.size < 50) {
throw Exception()
}
@@ -71,6 +51,26 @@ internal class ExtensionGithubApi {
}
}
private suspend fun getExtensions(
repoBaseUrl: String,
isOfficialRepo: Boolean,
): List<Extension.Available> {
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(repoBaseUrl, isRepoSource = !isOfficialRepo)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
emptyList()
}
}
suspend fun checkForUpdates(
context: Context,
fromAvailableExtensionList: Boolean = false,
@@ -111,7 +111,10 @@ internal class ExtensionGithubApi {
return extensionsWithUpdate
}
private fun List<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
private fun List<ExtensionJsonObject>.toExtensions(
repoUrl: String,
isRepoSource: Boolean,
): List<Extension.Available> {
return this
.filter {
val libVersion = it.extractLibVersion()
@@ -126,25 +129,17 @@ internal class ExtensionGithubApi {
libVersion = it.extractLibVersion(),
lang = it.lang,
isNsfw = it.nsfw == 1,
hasReadme = it.hasReadme == 1,
hasChangelog = it.hasChangelog == 1,
sources = it.sources?.map(extensionSourceMapper).orEmpty(),
apkName = it.apk,
iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png",
iconUrl = "$repoUrl/icon/${it.pkg}.png",
repoUrl = repoUrl,
isRepoSource = isRepoSource,
)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "${getUrlPrefix()}apk/${extension.apkName}"
}
private fun getUrlPrefix(): String {
return if (requiresFallbackSource) {
FALLBACK_REPO_URL_PREFIX
} else {
REPO_URL_PREFIX
}
return "${extension.repoUrl}/apk/${extension.apkName}"
}
private fun ExtensionJsonObject.extractLibVersion(): Double {
@@ -152,9 +147,6 @@ internal class ExtensionGithubApi {
}
}
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/"
@Serializable
private data class ExtensionJsonObject(
val name: String,
@@ -164,8 +156,6 @@ private data class ExtensionJsonObject(
val code: Long,
val version: String,
val nsfw: Int,
val hasReadme: Int = 0,
val hasChangelog: Int = 0,
val sources: List<ExtensionSourceJsonObject>?,
)

View File

@@ -13,8 +13,6 @@ sealed class Extension {
abstract val libVersion: Double
abstract val lang: String?
abstract val isNsfw: Boolean
abstract val hasReadme: Boolean
abstract val hasChangelog: Boolean
data class Installed(
override val name: String,
@@ -24,8 +22,6 @@ sealed class Extension {
override val libVersion: Double,
override val lang: String,
override val isNsfw: Boolean,
override val hasReadme: Boolean,
override val hasChangelog: Boolean,
val pkgFactory: String?,
val sources: List<Source>,
val icon: Drawable?,
@@ -33,6 +29,8 @@ sealed class Extension {
val isObsolete: Boolean = false,
val isUnofficial: Boolean = false,
val isShared: Boolean,
val repoUrl: String? = null,
val isRepoSource: Boolean = false,
) : Extension()
data class Available(
@@ -43,11 +41,11 @@ sealed class Extension {
override val libVersion: Double,
override val lang: String,
override val isNsfw: Boolean,
override val hasReadme: Boolean,
override val hasChangelog: Boolean,
val sources: List<Source>,
val apkName: String,
val iconUrl: String,
val repoUrl: String,
val isRepoSource: Boolean,
) : Extension() {
data class Source(
@@ -75,7 +73,5 @@ sealed class Extension {
val signatureHash: String,
override val lang: String? = null,
override val isNsfw: Boolean = false,
override val hasReadme: Boolean = false,
override val hasChangelog: Boolean = false,
) : Extension()
}

View File

@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
import eu.kanade.tachiyomi.util.system.isDevFlavor
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
@@ -62,11 +63,6 @@ internal object ExtensionLoader {
// inorichi's key
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
/**
* List of the trusted signatures.
*/
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get()
private const val PRIVATE_EXTENSION_EXTENSION = "ext"
private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
@@ -123,6 +119,12 @@ internal object ExtensionLoader {
* @param context The application context.
*/
fun loadExtensions(context: Context): List<LoadResult> {
// Always make users trust unknown extensions on cold starts in non-dev builds
// due to inherent security risks
if (!isDevFlavor) {
preferences.trustedSignatures().delete()
}
val pkgManager = context.packageManager
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -329,8 +331,6 @@ internal object ExtensionLoader {
libVersion = libVersion,
lang = lang,
isNsfw = isNsfw,
hasReadme = hasReadme,
hasChangelog = hasChangelog,
sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
isUnofficial = !isOfficiallySigned(signatures),
@@ -394,6 +394,11 @@ internal object ExtensionLoader {
}
private fun hasTrustedSignature(signatures: List<String>): Boolean {
if (officialSignature in signatures) {
return true
}
val trustedSignatures = preferences.trustedSignatures().get()
return trustedSignatures.any { signatures.contains(it) }
}

View File

@@ -37,7 +37,6 @@ data class ExtensionDetailsScreen(
state = state,
onClickSourcePreferences = { navigator.push(SourcePreferencesScreen(it)) },
onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) },
onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) },
onClickEnableAll = { screenModel.toggleSources(true) },
onClickDisableAll = { screenModel.toggleSources(false) },
onClickClearCookies = screenModel::clearCookies,

View File

@@ -31,8 +31,6 @@ import uy.kohesive.injekt.api.get
private const val URL_EXTENSION_COMMITS =
"https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
private const val URL_EXTENSION_BLOB =
"https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"
class ExtensionDetailsScreenModel(
pkgName: String,
@@ -93,26 +91,11 @@ class ExtensionDetailsScreenModel(
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
if (extension.hasChangelog) {
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
}
// Falling back on GitHub commit history because there is no explicit changelog in extension
return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
}
fun getReadmeUrl(): String {
val extension = state.value.extension ?: return ""
if (!extension.hasReadme) {
return "https://tachiyomi.org/docs/faq/browse/extensions"
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
}
fun clearCookies() {
val extension = state.value.extension ?: return

View File

@@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
@@ -265,7 +267,10 @@ class BrowseSourceScreenModel(
else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id }
setDialog(
Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }),
Dialog.ChangeMangaCategory(
manga,
categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(),
),
)
}
}
@@ -338,7 +343,7 @@ class BrowseSourceScreenModel(
data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class ChangeMangaCategory(
val manga: Manga,
val initialSelection: List<CheckboxState.State<Category>>,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class Migrate(val newManga: Manga) : Dialog
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.util.fastMap
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
@@ -15,6 +16,7 @@ import eu.kanade.presentation.category.components.CategoryRenameDialog
import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.presentation.core.screens.LoadingScreen
@@ -52,22 +54,22 @@ class CategoryScreen : Screen() {
CategoryCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = screenModel::createCategory,
categories = successState.categories,
categories = successState.categories.fastMap { it.name }.toImmutableList(),
)
}
is CategoryDialog.Rename -> {
CategoryRenameDialog(
onDismissRequest = screenModel::dismissDialog,
onRename = { screenModel.renameCategory(dialog.category, it) },
categories = successState.categories,
category = dialog.category,
categories = successState.categories.fastMap { it.name }.toImmutableList(),
category = dialog.category.name,
)
}
is CategoryDialog.Delete -> {
CategoryDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteCategory(dialog.category.id) },
category = dialog.category,
category = dialog.category.name,
)
}
is CategoryDialog.SortAlphabetically -> {

View File

@@ -1,11 +1,8 @@
package eu.kanade.tachiyomi.ui.home
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
@@ -26,20 +23,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
@@ -59,7 +49,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import soup.compose.material.motion.MotionConstants
import soup.compose.material.motion.animation.materialFadeThroughIn
import soup.compose.material.motion.animation.materialFadeThroughOut
import tachiyomi.domain.library.service.LibraryPreferences
@@ -70,7 +59,6 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.pluralStringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.coroutines.cancellation.CancellationException
object HomeScreen : Screen() {
@@ -92,8 +80,6 @@ object HomeScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
var scale by remember { mutableFloatStateOf(1f) }
TabNavigator(
tab = LibraryTab,
key = TabNavigatorKey,
@@ -132,11 +118,6 @@ object HomeScreen : Screen() {
) { contentPadding ->
Box(
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 1f)
}
.padding(contentPadding)
.consumeWindowInsets(contentPadding),
) {
@@ -157,30 +138,10 @@ object HomeScreen : Screen() {
}
val goToLibraryTab = { tabNavigator.current = LibraryTab }
var handlingBack by remember { mutableStateOf(false) }
PredictiveBackHandler(enabled = handlingBack || tabNavigator.current != LibraryTab) { progress ->
handlingBack = true
val currentTab = tabNavigator.current
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.92f, LinearOutSlowInEasing.transform(backEvent.progress))
tabNavigator.current = if (backEvent.progress > 0.25f) tabs[0] else currentTab
}
goToLibraryTab()
} catch (e: CancellationException) {
tabNavigator.current = currentTab
} finally {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
) { value, _ ->
scale = value
}
handlingBack = false
}
}
BackHandler(
enabled = tabNavigator.current != LibraryTab,
onBack = goToLibraryTab,
)
LaunchedEffect(Unit) {
launch {

View File

@@ -28,9 +28,11 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@@ -661,13 +663,15 @@ class LibraryScreenModel(
val common = getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect.
val mix = getMixCategories(mangaList)
val preselected = categories.map {
when (it) {
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
val preselected = categories
.map {
when (it) {
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
}
}
}
.toImmutableList()
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
}
}
@@ -683,7 +687,10 @@ class LibraryScreenModel(
sealed interface Dialog {
data object SettingsSheet : Dialog
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
data class ChangeCategory(
val manga: List<Manga>,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class DeleteManga(val manga: List<Manga>) : Dialog
}

View File

@@ -11,6 +11,7 @@ import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -64,7 +65,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
import eu.kanade.tachiyomi.data.updater.RELEASE_URL
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
@@ -222,13 +223,14 @@ class MainActivity : BaseActivity() {
contentWindowInsets = scaffoldInsets,
) { contentPadding ->
// Consume insets already used by app state banners
// Shows current screen
DefaultNavigatorScreenTransition(
navigator = navigator,
Box(
modifier = Modifier
.padding(contentPadding)
.consumeWindowInsets(contentPadding),
)
) {
// Shows current screen
DefaultNavigatorScreenTransition(navigator = navigator)
}
}
// Pop source-related screens when incognito mode is turned off
@@ -335,7 +337,7 @@ class MainActivity : BaseActivity() {
// Extensions updates
LaunchedEffect(Unit) {
try {
ExtensionGithubApi().checkForUpdates(context)
ExtensionApi().checkForUpdates(context)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}

View File

@@ -104,7 +104,7 @@ class MangaScreen(
MangaScreen(
state = successState,
snackbarHostState = screenModel.snackbarHostState,
fetchInterval = successState.manga.fetchInterval,
nextUpdate = successState.manga.expectedNextUpdate,
isTabletUi = isTabletUi(),
chapterSwipeStartAction = screenModel.chapterSwipeStartAction,
chapterSwipeEndAction = screenModel.chapterSwipeEndAction,
@@ -146,7 +146,7 @@ class MangaScreen(
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.manga.favorite },
onEditFetchIntervalClicked = screenModel::showSetFetchIntervalDialog.takeIf {
screenModel.isUpdateIntervalEnabled && successState.manga.favorite
successState.manga.favorite
},
onMigrateClicked = {
navigator.push(MigrateSearchScreen(successState.manga.id))
@@ -243,9 +243,10 @@ class MangaScreen(
is MangaScreenModel.Dialog.SetFetchInterval -> {
SetIntervalDialog(
interval = dialog.manga.fetchInterval,
nextUpdate = dialog.manga.nextUpdate,
nextUpdate = dialog.manga.expectedNextUpdate,
onDismissRequest = onDismissRequest,
onValueChanged = { screenModel.setFetchInterval(dialog.manga, it) },
onValueChanged = { interval: Int -> screenModel.setFetchInterval(dialog.manga, interval) }
.takeIf { screenModel.isUpdateIntervalEnabled },
)
}
}

View File

@@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.catch
@@ -360,7 +362,7 @@ class MangaScreenModel(
successState.copy(
dialog = Dialog.ChangeCategory(
manga = manga,
initialSelection = categories.mapAsCheckboxState { it.id in selection },
initialSelection = categories.mapAsCheckboxState { it.id in selection }.toImmutableList(),
),
)
}
@@ -992,7 +994,10 @@ class MangaScreenModel(
// Track sheet - end
sealed interface Dialog {
data class ChangeCategory(val manga: Manga, val initialSelection: List<CheckboxState<Category>>) : Dialog
data class ChangeCategory(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class SetFetchInterval(val manga: Manga) : Dialog

View File

@@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import tachiyomi.core.preference.toggle
import tachiyomi.core.storage.UniFileTempFileManager
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withIOContext
@@ -85,6 +86,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val downloadProvider: DownloadProvider = Injekt.get(),
private val tempFileManager: UniFileTempFileManager = Injekt.get(),
private val imageSaver: ImageSaver = Injekt.get(),
preferences: BasePreferences = Injekt.get(),
val readerPreferences: ReaderPreferences = Injekt.get(),
@@ -269,7 +271,7 @@ class ReaderViewModel @JvmOverloads constructor(
val context = Injekt.get<Application>()
val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
loader = ChapterLoader(context, downloadManager, downloadProvider, tempFileManager, manga, source)
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
Result.success(true)
@@ -904,6 +906,7 @@ class ReaderViewModel @JvmOverloads constructor(
private fun deletePendingChapters() {
viewModelScope.launchNonCancellable {
downloadManager.deletePendingChapters()
tempFileManager.deleteTempFiles()
}
}

View File

@@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.core.i18n.stringResource
import tachiyomi.core.storage.toTempFile
import tachiyomi.core.storage.UniFileTempFileManager
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.manga.model.Manga
@@ -24,6 +24,7 @@ class ChapterLoader(
private val context: Context,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val tempFileManager: UniFileTempFileManager,
private val manga: Manga,
private val source: Source,
) {
@@ -85,17 +86,24 @@ class ChapterLoader(
skipCache = true,
)
return when {
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider)
isDownloaded -> DownloadPageLoader(
chapter,
manga,
source,
downloadManager,
downloadProvider,
tempFileManager,
)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file.toTempFile(context))
is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file))
is Format.Rar -> try {
RarPageLoader(format.file.toTempFile(context))
RarPageLoader(tempFileManager.createTempFile(format.file))
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(format.file.toTempFile(context))
is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file))
}
}
source is HttpSource -> HttpPageLoader(chapter, source)

View File

@@ -10,7 +10,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import tachiyomi.core.storage.toTempFile
import tachiyomi.core.storage.UniFileTempFileManager
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy
@@ -23,6 +23,7 @@ internal class DownloadPageLoader(
private val source: Source,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val tempFileManager: UniFileTempFileManager,
) : PageLoader() {
private val context: Application by injectLazy()
@@ -46,8 +47,8 @@ internal class DownloadPageLoader(
zipPageLoader?.recycle()
}
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it }
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it }
return loader.getPages()
}

View File

@@ -40,7 +40,6 @@ class SettingsScreen(
Destination.Tracking.id -> SettingsTrackingScreen
else -> SettingsMainScreen
},
onBackPressed = null,
content = {
val pop: () -> Unit = {
if (it.canPop) {
@@ -62,7 +61,6 @@ class SettingsScreen(
Destination.Tracking.id -> SettingsTrackingScreen
else -> SettingsAppearanceScreen
},
onBackPressed = null,
) {
val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)
TwoPanelBox(

View File

@@ -4,11 +4,9 @@ package eu.kanade.tachiyomi.util.view
import android.content.res.Resources
import android.graphics.Rect
import android.os.Build
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.RoundedCorner
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -97,22 +95,3 @@ fun View?.isVisibleOnScreen(): Boolean {
Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
return actualPosition.intersect(screen)
}
/**
* Returns window radius (in pixel) applied to this view
*/
fun View.getWindowRadius(): Int {
val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val windowInsets = rootWindowInsets
listOfNotNull(
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT),
)
.minOfOrNull { it.radius }
} else {
null
}
return rad ?: 0
}