Update Kotatsu parsers lib

master
Zakhar Timoshenko 2 years ago
parent 47fffb5541
commit 4acbe85ea1
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -4,279 +4,6 @@
<option name="frameScreenshot" value="true" />
</component>
<component name="direct_access_persist.xml">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="q2q" />
<option name="id" value="q2q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold3" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1768" />
<option name="screenY" value="2208" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="shiba_beta" />
<option name="id" value="shiba_beta" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="x1q" />
<option name="id" value="x1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S20" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1440" />
<option name="screenY" value="3200" />
</PersistentDeviceSelectionData>
</list>
</option>
<option name="selectedCloudProject" value="api-7108673381507456403-50668" />
<option name="selectedCloudProject" />
</component>
</project>

@ -173,7 +173,7 @@ dependencies {
kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler:1.2.0")
implementation("com.github.KotatsuApp:kotatsu-parsers:7d2f5696f5") {
implementation("com.github.KotatsuApp:kotatsu-parsers:b404b44008") {
exclude(group = "org.json", module = "json")
}
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "dbe1dcac0f49c5ae2ac88d88aa280081",
"identityHash": "90b73386d5c61c2ddf46d6354ca2f1b6",
"entities": [
{
"tableName": "manga",
@ -199,7 +199,7 @@
},
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `added_in` INTEGER NOT NULL, `used_at` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`source`))",
"fields": [
{
"fieldPath": "source",
@ -218,6 +218,24 @@
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "addedIn",
"columnName": "added_in",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "used_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
@ -851,7 +869,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dbe1dcac0f49c5ae2ac88d88aa280081')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90b73386d5c61c2ddf46d6354ca2f1b6')"
]
}
}

@ -15,6 +15,10 @@
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<queries>
<intent>

@ -86,6 +86,7 @@ import org.xtimms.shirizu.sections.feed.FeedScreen
import org.xtimms.shirizu.sections.history.HistoryTab
import org.xtimms.shirizu.sections.library.LibraryTab
import org.xtimms.shirizu.sections.onboarding.OnboardingScreen
import org.xtimms.shirizu.sections.profile.ProfileTab
import org.xtimms.shirizu.sections.search.SearchTab
import org.xtimms.shirizu.sections.settings.SettingsScreen
import org.xtimms.shirizu.sections.shelf.ShelfTab
@ -301,7 +302,6 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
object MainScreen : Screen() {
private val librarySearchEvent = Channel<String>()
private val openTabEvent = Channel<Tab>()
private val showBottomNavEvent = Channel<Boolean>()
@ -312,7 +312,8 @@ object MainScreen : Screen() {
// ShelfTab,
// HistoryTab,
ExploreTab(),
SearchTab
SearchTab,
// ProfileTab
)
@Composable
@ -331,13 +332,6 @@ object MainScreen : Screen() {
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(R.string.suggestions),
icon = Icons.Outlined.Creation,
onClick = {
navigator.push(SuggestionsScreen)
},
),
AppBar.Action(
title = stringResource(R.string.feed),
icon = Icons.Outlined.RssFeed,
@ -425,6 +419,7 @@ object MainScreen : Screen() {
// is Tab.History -> HistoryTab
is Tab.Explore -> ExploreTab()
is Tab.Search -> SearchTab
// is Tab.Profile -> ProfileTab
}
}
}
@ -508,5 +503,6 @@ object MainScreen : Screen() {
// data object History : Tab
data object Explore : Tab
data object Search : Tab
// data object Profile : Tab
}
}

@ -1,27 +0,0 @@
package org.xtimms.shirizu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
interface ContentCache {
val isCachingEnabled: Boolean
suspend fun getDetails(source: MangaSource, url: String): Manga?
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
data class Key(
val source: MangaSource,
val url: String,
)
}

@ -2,16 +2,19 @@ package org.xtimms.shirizu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
import org.xtimms.shirizu.core.cache.MemoryContentCache.Key as CacheKey
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
) : Iterable<CacheKey> {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null
if (value.isExpired) {
cache.remove(key)
@ -19,7 +22,7 @@ class ExpiringLruCache<T>(
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
operator fun set(key: CacheKey, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
@ -30,4 +33,8 @@ class ExpiringLruCache<T>(
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
fun remove(key: CacheKey) {
cache.remove(key)
}
}

@ -6,42 +6,54 @@ import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.utils.system.isLowRamDevice
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
@Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
private val isLowRam = application.isLowRamDevice()
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true
suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull()
}
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[Key(source, url)] = details
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[ContentCache.Key(source, url)] = details
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[Key(source, url)]?.awaitOrNull()
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[Key(source, url)] = pages
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[ContentCache.Key(source, url)] = pages
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
}
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[Key(source, url)] = related
}
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[ContentCache.Key(source, url)] = related
fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@ -67,4 +79,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
else -> cache.trimToSize(cache.maxSize / 2)
}
}
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.forEach { key ->
if (key.source == source) {
cache.remove(key)
}
}
}
data class Key(
val source: MangaSource,
val url: String,
)
}

@ -1,22 +0,0 @@
package org.xtimms.shirizu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class StubContentCache : ContentCache {
override val isCachingEnabled: Boolean = false
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
}

@ -54,7 +54,7 @@ import java.time.Instant
@Composable
fun RowScope.ReadButton(
info: HistoryInfo,
info: HistoryInfo?,
estimatedReadTime: String
) {
@ -63,13 +63,13 @@ fun RowScope.ReadButton(
val animatedCardContainerColor = animateColorAsState(
label = "animatedCardContainerColor",
targetValue = if (info.totalChapters == 0) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
targetValue = if (info?.totalChapters == 0) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
animationSpec = TweenSpec(500)
).value
val animatedCardContentColor = animateColorAsState(
label = "animatedCardContentColor",
targetValue = if (info.totalChapters == 0) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer,
targetValue = if (info?.totalChapters == 0) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer,
animationSpec = TweenSpec(500)
).value
@ -105,9 +105,9 @@ fun RowScope.ReadButton(
contentAlignment = Alignment.CenterEnd,
) {
BackgroundProgress(
if (info.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
if (!info.isValid) 0.1f else 0.33f,
info.history?.percent?.coerceIn(0f, 1f) ?: 0f
if (info?.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
if (info?.isValid == true) 0.1f else 0.33f,
info?.history?.percent?.coerceIn(0f, 1f) ?: 0f
)
Column(
modifier = Modifier
@ -143,7 +143,8 @@ fun RowScope.ReadButton(
animationSpec = infiniteRepeatable(tween(15000), RepeatMode.Restart)
)
val chaptersSubtitle = when {
!info.isValid -> stringResource(R.string.loading_)
info == null -> "null"
info.isValid -> stringResource(R.string.loading_)
info.currentChapter >= 0 -> when (infiniteTransition) {
1 -> stringResource(
R.string.chapter_d_of_d,
@ -162,7 +163,7 @@ fun RowScope.ReadButton(
)
}
Text(
text = if (info.history != null) {
text = if (info?.history != null) {
stringResource(R.string.continue_reading)
} else {
stringResource(R.string.read)

@ -1,11 +1,12 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.ArrowDropUp
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@ -30,28 +31,33 @@ fun SortChip(
Box(modifier) {
var expanded by remember { mutableStateOf(false) }
val arrowDrop = if (expanded) Icons.Outlined.ArrowDropUp else Icons.Outlined.ArrowDropDown
FilterChip(
selected = true,
onClick = { expanded = true },
label = {
Text(
text = currentSortOption.label(LocalContext.current.resources),
modifier = Modifier.animateContentSize(),
)
AnimatedContent(targetState = currentSortOption.label(LocalContext.current.resources), label = "Text") {
Text(text = it)
}
},
leadingIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
AnimatedContent(targetState = currentSortOption.icon(), label = "Icon") {
Icon(
imageVector = it,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
}
},
trailingIcon = {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
AnimatedContent(targetState = arrowDrop, label = "Arrow drop") {
Icon(
imageVector = it,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
}
},
)

@ -2,10 +2,15 @@ package org.xtimms.shirizu.core.components
import android.content.res.Resources
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowOutward
import androidx.compose.material.icons.outlined.DateRange
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import org.xtimms.shirizu.R
@ -35,5 +40,12 @@ internal fun ColumnScope.SortDropdownMenuContent(
internal fun SortOption.label(resources: Resources): String = when (this) {
SortOption.ALPHABETICAL -> resources.getString(R.string.sort_alphabetically)
SortOption.PROGRESS -> resources.getString(R.string.progress)
SortOption.DATE_ADDED -> resources.getString(R.string.sort_date_added)
}
internal fun SortOption.icon(): ImageVector = when (this) {
SortOption.ALPHABETICAL -> Icons.Outlined.SortByAlpha
SortOption.PROGRESS -> Icons.Outlined.ArrowOutward
SortOption.DATE_ADDED -> Icons.Outlined.DateRange
}

@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder
@ -65,6 +66,9 @@ abstract class MangaSourcesDao {
source = source,
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
)
upsert(entity)
}

@ -12,4 +12,7 @@ data class MangaSourceEntity(
val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
@ColumnInfo(name = "added_in") val addedIn: Int,
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
@ColumnInfo(name = "pinned") val isPinned: Boolean,
)

@ -0,0 +1,8 @@
package org.xtimms.shirizu.core.exceptions
import org.koitharu.kotatsu.parsers.model.Manga
class UnsupportedSourceException(
message: String?,
val manga: Manga?,
) : IllegalArgumentException(message)

@ -3,6 +3,7 @@ package org.xtimms.shirizu.core.model
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.utils.system.iterator
import java.text.DecimalFormat
@ -61,4 +62,4 @@ fun MangaChapter.formatNumber(): String? {
}
val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL
get() = source == LocalMangaSource

@ -1,13 +1,72 @@
package org.xtimms.shirizu.core.model
import android.content.Context
import androidx.annotation.StringRes
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
import org.xtimms.shirizu.utils.system.getDisplayName
import org.xtimms.shirizu.utils.system.toLocale
fun MangaSource(name: String): MangaSource {
MangaSource.entries.forEach {
data object LocalMangaSource : MangaSource {
override val name = "LOCAL"
}
data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
MangaParserSource.entries.forEach {
if (it.name == name) return it
}
return MangaSource.DUMMY
return UnknownMangaSource
}
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
@get:StringRes
val ContentType.titleResId
get() = when (this) {
ContentType.MANGA -> R.string.content_type_manga
ContentType.HENTAI -> R.string.hentai
ContentType.COMICS -> R.string.comics
ContentType.OTHER -> R.string.other_source
}
fun MangaSource.getTitle(context: Context): String = when (this) {
is MangaSourceInfo -> mangaSource.getTitle(context)
is MangaParserSource -> title
LocalMangaSource -> context.getString(R.string.local_storage)
is ExternalMangaSource -> resolveName(context)
else -> context.getString(R.string.unknown)
}
fun MangaSource.getSummary(context: Context): String? = when (this) {
is MangaSourceInfo -> mangaSource.getSummary(context)
is MangaParserSource -> {
val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
is ExternalMangaSource -> context.getString(R.string.external_source)
else -> null
}

@ -0,0 +1,9 @@
package org.xtimms.shirizu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceInfo(
val mangaSource: MangaSource,
val isEnabled: Boolean,
val isPinned: Boolean,
) : MangaSource by mangaSource

@ -0,0 +1,15 @@
package org.xtimms.shirizu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.MangaSource
class MangaSourceParceler : Parceler<MangaSource> {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}

@ -18,7 +18,7 @@ object MangaTagParceler : Parceler<MangaTag> {
override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(key)
parcel.writeSerializable(source)
parcel.writeString(source.name)
}
}

@ -30,7 +30,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeSerializable(source)
parcel.writeString(source.name)
}
override fun create(parcel: Parcel) = ParcelableManga(

@ -12,7 +12,7 @@ import org.koitharu.kotatsu.parsers.util.mergeWith
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
import org.xtimms.shirizu.core.parser.ParserMangaRepository
import java.net.IDN
import javax.inject.Inject
import javax.inject.Singleton
@ -26,7 +26,7 @@ class CommonHeadersInterceptor @Inject constructor(
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else {
if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}")

@ -0,0 +1,42 @@
package org.xtimms.shirizu.core.parser
import android.graphics.Canvas
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.bitmap.Rect
import java.io.OutputStream
import android.graphics.Bitmap as AndroidBitmap
import android.graphics.Rect as AndroidRect
class BitmapWrapper private constructor(
private val androidBitmap: AndroidBitmap
) : Bitmap {
private val canvas by lazy { Canvas(androidBitmap) }
override val height: Int
get() = androidBitmap.height
override val width: Int
get() = androidBitmap.width
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
}
fun compressTo(output: OutputStream) {
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
}
companion object {
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
)
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true)
)
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
}
}

@ -0,0 +1,105 @@
package org.xtimms.shirizu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.cache.MemoryContentCache
import org.xtimms.shirizu.core.cache.SafeDeferred
import org.xtimms.shirizu.utils.MultiMutex
import org.xtimms.shirizu.utils.lang.processLifecycleScope
abstract class CachingMangaRepository(
private val cache: MemoryContentCache,
) : MangaRepository {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
getPagesImpl(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
getDetailsImpl(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
fun invalidateCache() {
cache.clear(source)
}
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
@OptIn(ExperimentalStdlibApi::class)
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
}

@ -0,0 +1,51 @@
package org.xtimms.shirizu.core.parser
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.shirizu.core.exceptions.UnsupportedSourceException
import java.util.EnumSet
import java.util.Locale
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("This manga source is not supported", manga)
}
}

@ -13,6 +13,7 @@ import javax.inject.Provider
@Reusable
class MangaDataRepository @Inject constructor(
private val db: ShirizuDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
) {
suspend fun findMangaById(mangaId: Long): Manga? {
@ -23,6 +24,13 @@ class MangaDataRepository @Inject constructor(
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
}
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> findMangaById(intent.mangaId)
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
else -> null
}
suspend fun storeManga(manga: Manga) {
db.withTransaction {
val tags = manga.tags.toEntities()

@ -0,0 +1,124 @@
package org.xtimms.shirizu.core.parser
import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import org.xtimms.shirizu.core.model.MangaSource
import org.xtimms.shirizu.core.model.UnknownMangaSource
import org.xtimms.shirizu.core.model.isNsfw
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
import javax.inject.Inject
@Reusable
class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository,
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
}
private suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName)
require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source)
return repo.findExact(
url = uri.getQueryParameter("url"),
title = uri.getQueryParameter("name"),
)
}
private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it
}
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as ParserMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
return repo.findExact(uri.toString().toRelativeUrl(host), null)
}
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title))
if (url != null) {
list.find { it.url == url }?.let {
return it
}
}
list.minByOrNull { it.title.levenshteinDistance(title) }
?.takeIf { it.title.almostEquals(title, 0.2f) }
?.let { return it }
}
val seed = getDetailsNoCache(
getSeedManga(source, url ?: return null, title),
)
return runCatchingCancellable {
val seedTitle = seed.title.ifEmpty {
seed.altTitle
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle))
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
}
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
id = run {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
h
},
title = title.orEmpty(),
altTitle = null,
url = url,
publicUrl = "",
rating = 0.0f,
isNsfw = source.isNsfw(),
coverUrl = "",
tags = emptySet(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
chapters = null,
source = source,
)
}

@ -2,6 +2,7 @@ package org.xtimms.shirizu.core.parser
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
@ -10,8 +11,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
@ -20,6 +26,7 @@ import org.xtimms.shirizu.core.network.MangaHttpClient
import org.xtimms.shirizu.core.network.cookies.MutableCookieJar
import org.xtimms.shirizu.core.prefs.SourceSettings
import org.xtimms.shirizu.utils.system.configureForParser
import org.xtimms.shirizu.utils.system.requireBody
import org.xtimms.shirizu.utils.system.sanitizeHeaderValue
import org.xtimms.shirizu.utils.system.toList
import java.lang.ref.WeakReference
@ -72,6 +79,30 @@ class MangaLoaderContextImpl @Inject constructor(
return LocaleListCompat.getAdjustedDefault().toList()
}
override fun createBitmap(width: Int, height: Int): Bitmap {
return BitmapWrapper.create(width, height)
}
override fun redrawImageResponse(
response: Response,
redraw: (image: Bitmap) -> Bitmap
): Response {
val image = response.requireBody().byteStream()
val opts = BitmapFactory.Options()
opts.inMutable = true
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
val body = Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
return response.newBuilder()
.body(body)
.build()
}
@MainThread
private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also {

@ -2,8 +2,8 @@ package org.xtimms.shirizu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaParserSource
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return loaderContext.newParserInstance(source)
}

@ -1,21 +1,28 @@
package org.xtimms.shirizu.core.parser
import android.content.Context
import androidx.annotation.AnyThread
import androidx.paging.PagingSource
import androidx.collection.ArrayMap
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.shirizu.core.cache.ContentCache
import org.xtimms.shirizu.core.cache.MemoryContentCache
import org.xtimms.shirizu.core.model.LocalMangaSource
import org.xtimms.shirizu.core.model.MangaSourceInfo
import org.xtimms.shirizu.core.model.UnknownMangaSource
import org.xtimms.shirizu.core.parser.external.ExternalMangaRepository
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
import org.xtimms.shirizu.core.parser.local.LocalMangaRepository
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@ -30,6 +37,8 @@ interface MangaRepository {
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean
@ -50,30 +59,58 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
}
@Singleton
class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
private val contentCache: MemoryContentCache,
) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
@AnyThread
fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) {
return localMangaRepository
when (source) {
is MangaSourceInfo -> return create(source.mangaSource)
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
}
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
val repository = createRepository(source)
if (repository != null) {
cache[source] = WeakReference(repository)
repository
} else {
EmptyMangaRepository(source)
}
}
}
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
)
is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository(
contentResolver = context.contentResolver,
source = source,
cache = contentCache,
)
cache[source] = WeakReference(repository)
repository
} else {
EmptyMangaRepository(source)
}
else -> null
}
}
}

@ -1,7 +1,6 @@
package org.xtimms.shirizu.core.parser
import android.util.Log
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -27,17 +26,17 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.cache.ContentCache
import org.xtimms.shirizu.core.cache.MemoryContentCache
import org.xtimms.shirizu.core.cache.SafeDeferred
import org.xtimms.shirizu.core.prefs.SourceSettings
import org.xtimms.shirizu.utils.lang.processLifecycleScope
import java.util.Locale
@OptIn(InternalParsersApi::class)
class RemoteMangaRepository(
class ParserMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
) : MangaRepository, Interceptor {
private val cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
override val source: MangaSource
get() = parser.source
@ -51,6 +50,12 @@ class RemoteMangaRepository(
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) {
getConfig().defaultSortOrder = value
}
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
@ -70,7 +75,7 @@ class RemoteMangaRepository(
get() = parser.configKeyDomain.presetValues
val headers: Headers
get() = parser.headers
get() = parser.getRequestHeaders()
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
@ -84,16 +89,9 @@ class RemoteMangaRepository(
return parser.getList(offset, filter)
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
parser.getPages(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
return pages.await()
}
override suspend fun getPagesImpl(
chapter: MangaChapter
): List<MangaPage> = parser.getPages(chapter)
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
@ -105,59 +103,19 @@ class RemoteMangaRepository(
suspend fun getFavicons(): Favicons = parser.getFavicons()
override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
return related.await()
}
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
parser.getDetails(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
return details.await()
}
override suspend fun getDetailsImpl(manga: Manga): Manga = parser.getDetails(manga)
private fun getConfig() = parser.config as SourceSettings
@OptIn(ExperimentalStdlibApi::class)
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues.toList()
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = HashSet<Long>(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
fun isSlowdownEnabled(): Boolean {
return getConfig().isSlowdownEnabled
}
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
private fun getConfig() = parser.config as SourceSettings
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true
}

@ -0,0 +1,264 @@
package org.xtimms.shirizu.core.parser.external
import android.content.ContentResolver
import android.database.Cursor
import androidx.collection.ArraySet
import androidx.core.database.getStringOrNull
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import org.xtimms.shirizu.core.cache.MemoryContentCache
import org.xtimms.shirizu.core.parser.CachingMangaRepository
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
private val capabilities by lazy { queryCapabilities() }
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}.orEmpty()
}
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
val chapters = async { queryChapters(manga.url) }
val details = queryDetails(manga.url)
Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters.await(),
source = source,
)
}
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/chapters".toUri()
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(0),
url = cursor.getString(1),
preview = cursor.getStringOrNull(2),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
}
override suspend fun getPageUrl(page: MangaPage): String = page.url
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/tags".toUri()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(0),
title = cursor.getString(1),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
}
override suspend fun getLocales(): Set<Locale> = emptySet()
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
checkNotNull(
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
},
)
}
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(0),
name = cursor.getString(1),
number = cursor.getFloat(2),
volume = cursor.getInt(3),
url = cursor.getString(4),
scanlator = cursor.getStringOrNull(5),
uploadDate = cursor.getLong(6),
branch = cursor.getStringOrNull(7),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun Cursor.getManga() = Manga(
id = getLong(0),
title = getString(1),
altTitle = getStringOrNull(2),
url = getString(3),
publicUrl = getString(4),
rating = getFloat(5),
isNsfw = getInt(6) > 1,
coverUrl = getString(7),
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
author = optString(10),
largeCoverUrl = optString(11),
description = optString(12),
chapters = emptyList(),
source = source,
)
private fun Cursor.optString(columnIndex: Int): String? {
return if (isNull(columnIndex)) {
null
} else {
getString(columnIndex)
}
}
private fun queryCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(0)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(1)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(2)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getInt(3) > 1,
isTagsExclusionSupported = cursor.getInt(4) > 1,
isSearchSupported = cursor.getInt(5) > 1,
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(7)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
)
} else {
null
}
}
}
private class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
}

@ -0,0 +1,30 @@
package org.xtimms.shirizu.core.parser.external
import android.content.Context
import org.koitharu.kotatsu.parsers.model.MangaSource
data class ExternalMangaSource(
val packageName: String,
val authority: String,
) : MangaSource {
override val name: String
get() = "content:$packageName/$authority"
private var cachedName: String? = null
fun isAvailable(context: Context): Boolean {
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
}
fun resolveName(context: Context): String {
cachedName?.let {
return it
}
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
return info?.loadLabel(pm)?.toString()?.also {
cachedName = it
} ?: authority
}
}

@ -1,13 +1,20 @@
package org.xtimms.shirizu.core.parser.favicon
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
@ -15,7 +22,9 @@ import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -27,8 +36,10 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.xtimms.shirizu.core.cache.CacheDir
import org.xtimms.shirizu.core.model.MangaSource
import org.xtimms.shirizu.core.parser.EmptyMangaRepository
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
import org.xtimms.shirizu.core.parser.ParserMangaRepository
import org.xtimms.shirizu.core.parser.external.ExternalMangaRepository
import org.xtimms.shirizu.utils.lang.writeAllCancellable
import org.xtimms.shirizu.utils.withExtraCloseable
import java.net.HttpURLConnection
@ -46,14 +57,27 @@ class FaviconFetcher(
) : Fetcher {
private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult(
drawable = ColorDrawable(Color.WHITE),
isSampled = false,
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
}
}
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
@ -83,6 +107,20 @@ class FaviconFetcher(
throwNSEE(lastError)
}
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source
val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) {
val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
}
return DrawableResult(
drawable = icon.nonAdaptive(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder()
.url(url)
@ -167,12 +205,20 @@ class FaviconFetcher(
}
}
private fun Drawable.nonAdaptive() =
if (this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory(
context: Context,
private val okHttpClient: OkHttpClient,
okHttpClientLazy: Lazy<OkHttpClient>,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> {
private val okHttpClient by okHttpClientLazy
private val diskCache = lazy {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()

@ -23,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.model.LocalManga
import org.xtimms.shirizu.core.model.LocalMangaSource
import org.xtimms.shirizu.core.model.isLocal
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput
@ -49,7 +50,7 @@ class LocalMangaRepository @Inject constructor(
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) : MangaRepository {
override val source = MangaSource.LOCAL
override val source = LocalMangaSource
private val locks = MultiMutex<Long>()
private val localMappingCache = LocalMangaMappingCache()
@ -60,6 +61,10 @@ class LocalMangaRepository @Inject constructor(
override val states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST // TODO
set(value) {}
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
if (offset > 0) {
return emptyList()
@ -94,7 +99,7 @@ class LocalMangaRepository @Inject constructor(
}
override suspend fun getDetails(manga: Manga): Manga = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
manga.source != LocalMangaSource -> requireNotNull(findSavedManga(manga)?.manga) {
"Manga is not local or saved"
}

@ -5,6 +5,7 @@ import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@ -59,7 +60,7 @@ class MangaIndex(source: String?) {
}
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
val source = MangaSource.valueOf(json.getString("source"))
val source = MangaParserSource.valueOf(json.getString("source"))
Manga(
id = json.getLong("id"),
title = json.getString("title"),

@ -10,6 +10,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.xtimms.shirizu.core.model.LocalManga
import org.xtimms.shirizu.core.model.LocalMangaSource
import org.xtimms.shirizu.core.parser.local.MangaIndex
import org.xtimms.shirizu.core.parser.local.hasCbzExtension
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
@ -47,7 +48,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
)
val manga = info?.copy2(
source = MangaSource.LOCAL,
source = LocalMangaSource,
url = mangaUri,
coverUrl = cover,
largeCoverUrl = cover,
@ -59,14 +60,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
// old downloads
chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
c.copy(url = file.toUri().toString(), source = LocalMangaSource)
},
) ?: Manga(
id = root.absolutePath.longHashCode(),
title = root.name.toHumanReadable(),
url = mangaUri,
publicUrl = mangaUri,
source = MangaSource.LOCAL,
source = LocalMangaSource,
coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter(
@ -74,7 +75,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(),
number = 0f,
volume = 0,
source = MangaSource.LOCAL,
source = LocalMangaSource,
uploadDate = f.creationTime,
url = f.toUri().toString(),
scanlator = null,
@ -106,7 +107,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
}
} else {
ZipFile(file).use { zip ->
@ -121,7 +122,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
id = pageUri.longHashCode(),
url = pageUri,
preview = null,
source = MangaSource.LOCAL,
source = LocalMangaSource,
)
}
}

@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.xtimms.shirizu.core.model.LocalManga
import org.xtimms.shirizu.core.model.LocalMangaSource
import org.xtimms.shirizu.core.parser.local.MangaIndex
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
import org.xtimms.shirizu.utils.AlphanumComparator
@ -47,12 +48,12 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
)
return@use info.copy2(
source = MangaSource.LOCAL,
source = LocalMangaSource,
url = fileUri,
coverUrl = cover,
largeCoverUrl = cover,
chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL)
c.copy(url = fileUri, source = LocalMangaSource)
},
)
}
@ -70,7 +71,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
title = title,
url = fileUri,
publicUrl = fileUri,
source = MangaSource.LOCAL,
source = LocalMangaSource,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator())
.mapIndexed { i, s ->
@ -79,7 +80,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
name = s.ifEmpty { title },
number = 0f,
volume = 0,
source = MangaSource.LOCAL,
source = LocalMangaSource,
uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(),
scanlator = null,
@ -135,7 +136,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
id = entryUri.longHashCode(),
url = entryUri,
preview = null,
source = MangaSource.LOCAL,
source = LocalMangaSource,
)
}
}

@ -6,13 +6,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.LocalMangaSource
class LocalMangaUtil(
private val manga: Manga,
) {
init {
require(manga.source == MangaSource.LOCAL) {
require(manga.source == LocalMangaSource) {
"Expected LOCAL source but ${manga.source} found"
}
}

@ -17,6 +17,7 @@ import org.xtimms.shirizu.App
import org.xtimms.shirizu.ui.theme.SEED
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.network.doh.DoHProvider
import org.xtimms.shirizu.sections.library.history.SortOption
import org.xtimms.shirizu.ui.monet.PaletteStyle
import org.xtimms.shirizu.utils.lang.processLifecycleScope
import org.xtimms.shirizu.utils.system.LocaleLanguageCodeMap
@ -36,6 +37,8 @@ const val CONFIGURE = "configure"
const val NOTIFICATION = "notification"
const val READING_TIME = "reading_time"
const val GRID_COLUMNS = "grid_columns"
const val SORT_OPTION = "sort_option"
const val DOH = "doh"
const val SYSTEM_DEFAULT = 0

@ -31,6 +31,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
} as T
}
@ -40,6 +41,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, value as String?)
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "")
}
}
}

@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.model.getPreferredBranch
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
import org.xtimms.shirizu.core.parser.ParserMangaRepository
import org.xtimms.shirizu.core.tracker.model.MangaTracking
import org.xtimms.shirizu.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.data.repository.HistoryRepository
@ -51,7 +51,7 @@ class Tracker @Inject constructor(
): MangaUpdates = withMangaLock(track.manga.id) {
val updates = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
require(repo is ParserMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
compare(track, manga, getBranch(manga))
}.getOrElse { error ->

@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
@ -123,22 +122,6 @@ class MangaSearchRepository @Inject constructor(
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
}
fun getSourcesSuggestion(query: String, limit: Int): List<MangaSource> {
if (query.length < 3) {
return emptyList()
}
val skipNsfw = !AppSettings.isNSFWEnabled()
val sources = sourcesRepository.allMangaSources
.filter { x ->
(x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true)
}
return if (limit == 0) {
sources
} else {
sources.take(limit)
}
}
fun saveSearchQuery(query: String) {
recentSuggestions.saveRecentQuery(query, null)
}

@ -1,23 +1,38 @@
package org.xtimms.shirizu.data.repository
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.database.dao.MangaSourcesDao
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
import org.xtimms.shirizu.core.model.MangaSource
import org.xtimms.shirizu.core.model.MangaSourceInfo
import org.xtimms.shirizu.core.model.getTitle
import org.xtimms.shirizu.core.model.isNsfw
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.KotatsuAppSettings
import org.xtimms.shirizu.core.prefs.observeAsFlow
@ -25,24 +40,28 @@ import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder
import org.xtimms.shirizu.utils.ReversibleHandle
import java.util.Collections
import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@Reusable
@Singleton
class MangaSourcesRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: ShirizuDatabase,
private val settings: KotatsuAppSettings,
) {
private val isNewSourcesAssimilated = AtomicBoolean(false)
private val dao: MangaSourcesDao
get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
remove(MangaSource.DUMMY)
private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
if (!BuildConfig.DEBUG) {
remove(MangaParserSource.DUMMY)
}
}
val allMangaSources: Set<MangaSource>
val allMangaSources: Set<MangaParserSource>
get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> {
@ -54,7 +73,8 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled)
}
fun observeDisabledSources(): Flow<List<MangaSource>> = combine(
@OptIn(ExperimentalCoroutinesApi::class)
fun observeDisabledSources(): Flow<List<MangaParserSource>> = combine(
observeIsNsfwDisabled(),
observeSortOrder(),
) { skipNsfw, _ ->
@ -84,7 +104,7 @@ class MangaSourcesRepository @Inject constructor(
}.distinctUntilChanged()
}
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
fun observeEnabledSources(): Flow<List<MangaParserSource>> = combine(
observeIsNsfwDisabled(),
observeSortOrder(),
) { skipNsfw, order ->
@ -120,10 +140,13 @@ class MangaSourcesRepository @Inject constructor(
}
}
suspend fun assimilateNewSources(): Set<MangaSource> {
suspend fun assimilateNewSources(): Boolean {
if (isNewSourcesAssimilated.getAndSet(true)) {
return false
}
val new = getNewSources()
if (new.isEmpty()) {
return emptySet()
return false
}
var maxSortKey = dao.getMaxSortKey()
val entities = new.map { x ->
@ -131,20 +154,20 @@ class MangaSourcesRepository @Inject constructor(
source = x.name,
isEnabled = false,
sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
)
}
dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) {
new.removeAll { x -> x.isNsfw() }
}
return new
return true
}
suspend fun isSetupRequired(): Boolean {
return dao.findAll().isEmpty()
}
private suspend fun getNewSources(): MutableSet<MangaSource> {
private suspend fun getNewSources(): MutableSet<out MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
@ -155,11 +178,11 @@ class MangaSourcesRepository @Inject constructor(
private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean,
): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
): List<MangaParserSource> {
val result = ArrayList<MangaParserSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.isNsfw()) {
continue
}
if (source in remoteSources) {
@ -169,6 +192,41 @@ class MangaSourcesRepository @Inject constructor(
return result
}
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
val intent = Intent("app.kotatsu.parser.PROVIDE_MANGA")
val pm = context.packageManager
return callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
trySendBlocking(intent)
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_VERIFIED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addDataScheme("package")
},
ContextCompat.RECEIVER_EXPORTED,
)
awaitClose { context.unregisterReceiver(receiver) }
}.onStart {
emit(null)
}.map {
pm.queryIntentContentProviders(intent, 0).map { resolveInfo ->
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
}.distinctUntilChanged()
}
private fun observeIsNsfwDisabled() = MutableStateFlow(AppSettings.isNSFWEnabled()).asStateFlow()
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_NEW) {
@ -178,4 +236,6 @@ class MangaSourcesRepository @Inject constructor(
private fun observeSortOrder() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_ORDER) {
sourcesSortOrder
}
private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
}

@ -5,6 +5,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.xtimms.shirizu.core.database.entity.BookmarkEntity
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
@ -83,6 +84,9 @@ class JsonDeserializer(private val json: JSONObject) {
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0),
lastUsedAt = json.getLongOrDefault("used_at", 0L),
isPinned = json.getBooleanOrDefault("pinned", false),
)
fun toMap(): Map<String, Any?> {

@ -1,6 +1,5 @@
package org.xtimms.shirizu.di
import android.app.Application
import android.content.Context
import android.text.Html
import androidx.work.WorkManager
@ -22,9 +21,6 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.cache.CacheDir
import org.xtimms.shirizu.core.cache.ContentCache
import org.xtimms.shirizu.core.cache.MemoryContentCache
import org.xtimms.shirizu.core.cache.StubContentCache
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.model.LocalManga
import org.xtimms.shirizu.core.network.MangaHttpClient
@ -38,6 +34,7 @@ import org.xtimms.shirizu.sections.reader.thumbnails.MangaPageFetcher
import org.xtimms.shirizu.utils.CoilImageGetter
import org.xtimms.shirizu.utils.system.connectivityManager
import org.xtimms.shirizu.utils.system.isLowRamDevice
import javax.inject.Provider
import javax.inject.Singleton
@Module
@ -70,7 +67,7 @@ interface ShirizuModule {
@Singleton
fun provideCoil(
@ApplicationContext context: Context,
@MangaHttpClient okHttpClient: OkHttpClient,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
mangaRepositoryFactory: MangaRepository.Factory,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
@ -81,37 +78,30 @@ interface ShirizuModule {
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
.build()
}
val okHttpClientLazy = lazy {
okHttpClientProvider.get().newBuilder().cache(null).build()
}
return ImageLoader.Builder(context)
.crossfade(500)
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
.okHttpClient { okHttpClientLazy.value }
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.IO)
.decoderDispatcher(Dispatchers.Default)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice())
.components(
ComponentRegistry.Builder()
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
.add(pageFetcherFactory)
.add(imageProxyInterceptor)
.build(),
).build()
}
@Provides
@Singleton
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)
}
}
@Provides
@Singleton
@LocalStorageChanges

@ -32,11 +32,13 @@ import androidx.compose.ui.unit.dp
import coil.ImageLoader
import coil.compose.AsyncImage
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.components.MangaCover
import org.xtimms.shirizu.core.model.getTitle
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo
@ -45,13 +47,13 @@ fun ClassicDetailsInfoBox(
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
altTitle: String?,
author: String?,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime,
historyInfo: HistoryInfo?,
readingTime: ReadingTime?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
@ -133,8 +135,8 @@ fun MangaInfoLarge(
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
altTitle: String?,
author: String?,
source: MangaSource,
state: MangaState?,
historyInfo: HistoryInfo?,
@ -168,7 +170,7 @@ fun MangaInfoLarge(
altTitle = altTitle,
author = author,
state = state,
source = source.title,
source = source.name,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
@ -185,12 +187,12 @@ fun MangaInfoSmall(
imageUrl: String,
favicon: Uri,
title: String,
altTitle: String,
author: String,
altTitle: String?,
author: String?,
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime,
historyInfo: HistoryInfo?,
readingTime: ReadingTime?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onCoverClick: () -> Unit,
@ -225,7 +227,7 @@ fun MangaInfoSmall(
altTitle = altTitle,
author = author,
state = state,
source = source.title,
source = source.name,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,

@ -49,6 +49,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
@ -82,6 +83,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.AsyncImage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@ -108,7 +110,7 @@ fun MangaAndSourceTitlesLarge(
title: String,
altTitle: String,
author: String,
source: MangaSource,
source: MangaParserSource,
state: MangaState?,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
@ -153,7 +155,7 @@ fun MangaAndSourceTitlesSmall(
altTitle: String,
author: String,
state: MangaState?,
source: MangaSource,
source: MangaParserSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isInShelf: Boolean,
@ -188,19 +190,16 @@ fun MangaAndSourceTitlesSmall(
}
}
@OptIn(
ExperimentalLayoutApi::class,
ExperimentalMaterial3Api::class
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DetailsContentInfo(
favicon: Uri,
title: String,
altTitle: String,
author: String,
altTitle: String?,
author: String?,
state: MangaState?,
source: String?,
historyInfo: HistoryInfo,
historyInfo: HistoryInfo?,
readingTime: ReadingTime?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
@ -224,7 +223,7 @@ fun DetailsContentInfo(
maxLines = 3
)
if (altTitle.isNotBlank()) {
if (!altTitle.isNullOrBlank()) {
Text(
text = altTitle,
style = MaterialTheme.typography.headlineSmall,
@ -235,7 +234,7 @@ fun DetailsContentInfo(
Spacer(modifier = Modifier.height(4.dp))
}
if (author.isNotEmpty()) {
if (!author.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -294,7 +293,7 @@ fun DetailsContentInfo(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip(
selected = false,
onClick = { onAddToShelfClicked() },
@ -354,7 +353,7 @@ fun DetailsContentInfo(
modifier = Modifier
.height(32.dp)
.width(56.dp),
onClick = { onAddToShelfClicked() /*TODO*/ },
onClick = { /*TODO*/ },
shape = MaterialTheme.shapes.small,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
) {
@ -579,14 +578,13 @@ private fun MangaSummary(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TagsChip(
tag: MangaTag,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
SuggestionChip(
modifier = modifier,
onClick = onClick,

@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.utils.lang.AssistContentScreen
import org.xtimms.shirizu.utils.lang.Screen
@ -26,13 +27,9 @@ import javax.inject.Inject
import javax.inject.Singleton
class DetailsScreen(
private val manga: Manga,
private val mangaId: Long,
val fromSource: Boolean = false,
) : Screen(), AssistContentScreen {
private var assistUrl: String? = null
override fun onProvideAssistUrl() = assistUrl
) : Screen() {
@Composable
override fun Content() {
@ -43,7 +40,7 @@ class DetailsScreen(
val screenModel =
getScreenModel<DetailsScreenModel, DetailsScreenModel.Factory> { factory ->
factory.create(context, manga, SnackbarHostState())
factory.create(context, mangaId, SnackbarHostState())
}
val state by screenModel.state.collectAsState()
@ -54,13 +51,13 @@ class DetailsScreen(
}
val successState = state as DetailsScreenModel.State.Success
val isOnlineSource = remember { successState.source != MangaSource.DUMMY && successState.source != MangaSource.LOCAL }
MangaScreen(
state = successState,
snackbarHostState = screenModel.snackbarHostState,
isTabletUi = isTabletUi(),
onBackClicked = navigator::pop,
onMangaClicked = { },
onWebViewClicked = {
},
@ -77,15 +74,4 @@ class DetailsScreen(
onCoverClicked = { },
)
}
private suspend fun getMangaUrl(manga_: Manga?, parser_: MangaParser?): String? {
val manga = manga_ ?: return null
val source = parser_ ?: return null
return try {
source.getDetails(manga).publicUrl
} catch (e: Exception) {
null
}
}
}

@ -3,53 +3,40 @@ package org.xtimms.shirizu.sections.details
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.hilt.ScreenModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.base.viewmodel.BaseStateScreenModel
import org.xtimms.shirizu.core.model.findById
import org.xtimms.shirizu.core.model.getPreferredBranch
import kotlinx.coroutines.withContext
import org.xtimms.shirizu.core.parser.MangaDataRepository
import org.xtimms.shirizu.data.repository.BookmarksRepository
import org.xtimms.shirizu.data.repository.FavouritesRepository
import org.xtimms.shirizu.data.repository.HistoryRepository
import org.xtimms.shirizu.sections.details.data.MangaDetails
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.domain.BranchComparator
import org.xtimms.shirizu.sections.details.domain.DetailsInteractor
import org.xtimms.shirizu.sections.details.domain.DetailsLoadUseCase
import org.xtimms.shirizu.sections.details.domain.ReadingTimeUseCase
import org.xtimms.shirizu.sections.details.domain.RelatedMangaUseCase
import org.xtimms.shirizu.sections.details.model.ChapterItem
import org.xtimms.shirizu.sections.details.model.HistoryInfo
import org.xtimms.shirizu.sections.details.model.MangaBranch
import org.xtimms.shirizu.utils.lang.onEachWhile
import org.xtimms.shirizu.utils.system.getDisplayMessage
class DetailsScreenModel @AssistedInject constructor(
@Assisted val context: Context,
@Assisted val manga: Manga,
@Assisted val mangaId: Long,
private val interactor: DetailsInteractor,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository,
private val favouritesRepository: FavouritesRepository,
@ -57,13 +44,16 @@ class DetailsScreenModel @AssistedInject constructor(
private val readingTimeUseCase: ReadingTimeUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase,
@Assisted val snackbarHostState: SnackbarHostState = SnackbarHostState(),
) : BaseStateScreenModel<DetailsScreenModel.State>(State.Loading) {
) : StateScreenModel<DetailsScreenModel.State>(State.Loading) {
private val successState: State.Success?
get() = state.value as? State.Success
private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
val events: Flow<Event> = _events.receiveAsFlow()
val details: MangaDetails?
get() = successState?.details
val history = historyRepository.observeOne(mangaId)
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private inline fun updateSuccessState(func: (State.Success) -> State.Success) {
mutableState.update {
@ -74,143 +64,56 @@ class DetailsScreenModel @AssistedInject constructor(
}
}
private var loadingJob: Job
var details = MutableStateFlow(MangaDetails(manga, null, null, false))
private val mangaImpl = details.map { x -> x.toManga() }
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(manga.id)
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val favouriteCategories = interactor.observeIsFavourite(manga.id)
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val remoteManga = MutableStateFlow<Manga?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val newChaptersCount = details.flatMapLatest { d ->
flowOf(0)
}.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null)
val historyInfo: StateFlow<HistoryInfo> = combine(
mangaImpl,
selectedBranch,
history,
) { m, b, h ->
HistoryInfo(m, b, h)
}.stateIn(
scope = screenModelScope + Dispatchers.Default,
started = SharingStarted.Eagerly,
initialValue = HistoryInfo(null, null, null),
)
@OptIn(ExperimentalCoroutinesApi::class)
val bookmarks = mangaImpl.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val relatedManga: StateFlow<List<Manga>> = mangaImpl.mapLatest {
if (it != null) {
relatedMangaUseCase.invoke(it).orEmpty()
} else {
emptyList()
}
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedChapterIds: HashSet<Long> = HashSet()
val branches: StateFlow<List<MangaBranch>> = combine(
details,
selectedBranch,
history,
) { m, b, h ->
val c = m.chapters
if (c.isEmpty()) {
return@combine emptyList()
init {
screenModelScope.launch(Dispatchers.IO) {
detailsLoadUseCase.invoke(mangaId)
.collectLatest { details ->
updateSuccessState {
it.copy(
details = details
)
}
}
}
val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch
c.map { x ->
MangaBranch(
name = x.key,
count = x.value.size,
isSelected = x.key == b,
isCurrent = h != null && x.key == currentBranch,
)
}.sortedWith(BranchComparator())
}.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = details.map {
it.isLoaded && it.allChapters.isEmpty()
}.stateIn(screenModelScope, SharingStarted.WhileSubscribed(), false)
screenModelScope.launch(Dispatchers.IO) {
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId))
val details = MangaDetails(manga, null, null, false)
val chapters = combine(
combine(
details,
history,
selectedBranch,
newChaptersCount,
bookmarks,
) { manga, history, branch, news, bookmarks ->
manga.mapChapters(
history,
news,
branch,
bookmarks,
)
},
chaptersQuery,
) { list, query ->
list.filterSearch(query)
}.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList())
val needRefreshInfo = !details.isLoaded
val readingTime = combine(
details,
selectedBranch,
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
}.stateIn(screenModelScope, SharingStarted.Lazily, null)
mutableState.update {
State.Success(
details = details
)
}
val selectedBranchValue: String?
get() = selectedBranch.value
if (screenModelScope.isActive) {
val fetchFromSourceTasks = listOf(
async { if (needRefreshInfo) fetchMangaFromSource() },
)
fetchFromSourceTasks.awaitAll()
}
init {
loadingJob = doLoad(manga.id)
updateSuccessState { it.copy(isRefreshingData = false) }
updateSuccessState { it.copy(isRefreshingData = false) }
}
}
private fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(mangaId)
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
val manga = it.toManga()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
true
}.catch { error ->
_events.send(Event.InternalError)
snackbarHostState.showSnackbar(error.message ?: error.stackTraceToString())
}.collect {
details.value = it
mutableState.update {
State.Success(
manga = details.value.toManga(),
source = details.value.toManga().source,
readingTime = checkNotNull(readingTime.value),
historyInfo = historyInfo.value,
availableScanlators = setOf(),
excludedScanlators = setOf(),
isRefreshingData = false
)
}
private suspend fun fetchMangaFromSource(manualFetch: Boolean = false) {
val state = successState ?: return
try {
withContext(Dispatchers.IO) {
val networkManga = state.details.toManga()
detailsLoadUseCase.getDetails(networkManga)
}
} catch (e: Throwable) {
screenModelScope.launch {
snackbarHostState.showSnackbar(message = with(context) { e.getDisplayMessage(resources) })
}
}
}
private fun List<ChapterItem>.filterSearch(query: String): List<ChapterItem> {
@ -222,12 +125,6 @@ class DetailsScreenModel @AssistedInject constructor(
}
}
fun removeFromHistory() {
launchJob(Dispatchers.Default) {
historyRepository.delete(setOf(manga.id))
}
}
sealed interface Event {
data object InternalError : Event
}
@ -238,12 +135,7 @@ class DetailsScreenModel @AssistedInject constructor(
@Immutable
data class Success(
val manga: Manga,
val source: MangaSource,
val historyInfo: HistoryInfo,
val readingTime: ReadingTime,
val availableScanlators: Set<String>,
val excludedScanlators: Set<String>,
val details: MangaDetails,
val isRefreshingData: Boolean = false,
) : State
}
@ -252,7 +144,7 @@ class DetailsScreenModel @AssistedInject constructor(
interface Factory : ScreenModelFactory {
fun create(
context: Context,
manga: Manga,
mangaId: Long,
snackbarHostState: SnackbarHostState
): DetailsScreenModel
}

@ -1,13 +0,0 @@
package org.xtimms.shirizu.sections.details
import org.xtimms.shirizu.core.base.state.UiState
import org.xtimms.shirizu.sections.details.data.MangaDetails
data class DetailsUiState(
val details: MangaDetails? = null,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {
override fun setLoading(value: Boolean) = copy(isLoading = value)
override fun setMessage(value: String?) = copy(message = value)
}

@ -1,16 +1,28 @@
package org.xtimms.shirizu.sections.details
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -19,13 +31,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.ClassicDetailsToolbar
import org.xtimms.shirizu.core.components.MangaHorizontalItem
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.VerticalFastScroller
import org.xtimms.shirizu.core.model.MangaHistory
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.core.parser.favicon.faviconUri
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo
import java.time.Instant
@ -36,6 +53,7 @@ fun MangaScreen(
snackbarHostState: SnackbarHostState,
isTabletUi: Boolean,
onBackClicked: () -> Unit,
onMangaClicked: (Manga) -> Unit,
onWebViewClicked: (() -> Unit)?,
onWebViewLongClicked: (() -> Unit)?,
onTrackingClicked: () -> Unit,
@ -56,6 +74,7 @@ fun MangaScreen(
snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked,
onTagSearch = onTagSearch,
onMangaClicked = onMangaClicked,
onRefresh = onRefresh,
)
}
@ -68,6 +87,7 @@ private fun MangaScreenSmallImpl(
snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit,
onTagSearch: (String) -> Unit,
onMangaClicked: (Manga) -> Unit,
onRefresh: () -> Unit,
) {
val chapterListState = rememberLazyListState()
@ -91,7 +111,7 @@ private fun MangaScreenSmallImpl(
label = "Top Bar Background",
)
ClassicDetailsToolbar(
title = state.manga?.title ?: "",
title = state.details.toManga().title,
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
navigateBack = { onBackClicked() },
@ -102,6 +122,8 @@ private fun MangaScreenSmallImpl(
val topPadding = contentPadding.calculateTopPadding()
val layoutDirection = LocalLayoutDirection.current
val relatedMangaListState = rememberLazyListState()
VerticalFastScroller(
listState = chapterListState,
topContentPadding = topPadding,
@ -121,16 +143,16 @@ private fun MangaScreenSmallImpl(
contentType = DetailsScreenItem.INFO_BOX,
) {
ClassicDetailsInfoBox(
imageUrl = state.manga.largeCoverUrl ?: state.manga.coverUrl,
favicon = state.manga.source.faviconUri(),
title = state.manga.title,
altTitle = state.manga.altTitle ?: stringResource(id = R.string.unknown),
author = state.manga.author ?: stringResource(id = R.string.unknown),
isNsfw = state.manga.isNsfw,
state = state.manga.state,
source = state.manga.source,
historyInfo = state.historyInfo,
readingTime = state.readingTime,
imageUrl = state.details.toManga().largeCoverUrl ?: state.details.toManga().coverUrl,
favicon = state.details.toManga().source.faviconUri(),
title = state.details.toManga().title,
altTitle = state.details.toManga().altTitle,
author = state.details.toManga().author,
isNsfw = state.details.toManga().isNsfw,
state = state.details.toManga().state,
source = state.details.toManga().source,
historyInfo = null,
readingTime = null,
isTabletUi = false,
appBarPadding = topPadding,
onCoverClick = { },
@ -147,12 +169,71 @@ private fun MangaScreenSmallImpl(
) {
ExpandableMangaDescription(
defaultExpandState = false,
description = state.manga?.description,
tagsProvider = { state.manga?.tags },
description = state.details.toManga().description,
tagsProvider = { state.details.toManga().tags },
onTagSearch = onTagSearch,
onCopyTagToClipboard = { },
)
}
/*item {
AnimatedVisibility(
visible = state.relatedManga.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut()
) {
Column {
Text(
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
text = stringResource(id = R.string.related_manga),
style = MaterialTheme.typography.titleLarge
)
LazyRow(
modifier = Modifier
.padding(top = 8.dp)
.sizeIn(minHeight = 100.dp),
state = relatedMangaListState,
contentPadding = PaddingValues(horizontal = 8.dp),
flingBehavior = rememberSnapFlingBehavior(lazyListState = relatedMangaListState)
) {
items(
items = state.relatedManga,
key = { it.id },
contentType = { it }
) {
MangaHorizontalItem(
manga = it,
onClick = { manga -> onMangaClicked(manga) },
onLongClick = { })
}
}
HorizontalDivider(modifier = Modifier.padding(16.dp))
}
}
}*/
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp),
text = stringResource(id = R.string.chapters),
style = MaterialTheme.typography.titleLarge
)
}
/*items(
items = state.chapters
) {
ChapterListItem(
title = it.chapter.name,
date = it.chapter.uploadDate,
scanlator = it.chapter.scanlator,
read = !it.isUnread,
bookmark = false,
selected = false,
onLongClick = { *//*TODO*//* },
onClick = { *//*TODO*//* }
)
}*/
}
}
}

@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.shirizu.core.ShirizuAsyncImage
@ -38,7 +39,7 @@ fun ModernDetailsInfoBox(
author: String,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
source: MangaParserSource,
historyInfo: HistoryInfo,
readingTime: ReadingTime?,
isTabletUi: Boolean,

@ -32,7 +32,7 @@ class DetailsLoadUseCase @Inject constructor(
) {
operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) { // wrong method
"Cannot resolve id $mangaId"
}
val local = if (!manga.isLocal) {
@ -52,7 +52,7 @@ class DetailsLoadUseCase @Inject constructor(
}
}
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {
suspend fun getDetails(seed: Manga) = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}.getOrThrow()

@ -24,7 +24,7 @@ data class ExploreTab(
get() {
val image = Icons.Outlined.Explore
return TabOptions(
index = 3u,
index = 1u,
title = stringResource(R.string.nav_explore),
icon = rememberVectorPainter(image),
)

@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.FastScrollLazyColumn
@ -234,7 +235,7 @@ fun CatalogScreen(
@Composable
fun SourceItem(
source: MangaSource,
source: MangaParserSource,
onClickItem: (MangaSource) -> Unit,
onLongClickItem: (MangaSource) -> Unit,
onClickMenu: (MangaSource) -> Unit,

@ -17,7 +17,9 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.getTitle
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
import org.xtimms.shirizu.sections.explore.sources.SourceUiModel
@ -34,7 +36,7 @@ class CatalogScreenModel @Inject constructor(
val events = _events.receiveAsFlow()
init {
val queryFilter: (String) -> ((MangaSource) -> Boolean) = { query ->
val queryFilter: (String) -> ((MangaParserSource) -> Boolean) = { query ->
filter@{ source ->
if (query.isEmpty()) return@filter true
query.split(",").any { _input ->

@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.R
import org.xtimms.shirizu.utils.LocaleHelper
@ -21,14 +22,14 @@ import java.util.Locale
@Composable
fun BaseSourceItem(
source: MangaSource,
source: MangaParserSource,
modifier: Modifier = Modifier,
showTypeInContent: Boolean = true,
onClickItem: () -> Unit = {},
onLongClickItem: () -> Unit = {},
icon: @Composable RowScope.(MangaSource) -> Unit = defaultIcon,
action: @Composable RowScope.(MangaSource) -> Unit = {},
content: @Composable RowScope.(MangaSource, String?) -> Unit = defaultContent,
content: @Composable RowScope.(MangaParserSource, String?) -> Unit = defaultContent,
) {
fun getPrettyContentTypeName(type: ContentType?, context: Context): String {
if (type == null) {
@ -60,7 +61,7 @@ private val defaultIcon: @Composable RowScope.(MangaSource) -> Unit = { source -
SourceIcon(source = source)
}
private val defaultContent: @Composable RowScope.(MangaSource, String?) -> Unit = { source, sourceLangString ->
private val defaultContent: @Composable RowScope.(MangaParserSource, String?) -> Unit = { source, sourceLangString ->
Column(
modifier = Modifier
.padding(horizontal = 24.dp)

@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.ScrollbarLazyColumn
@ -105,7 +106,7 @@ fun SourceHeader(
@Composable
fun SourceItem(
source: MangaSource,
source: MangaParserSource,
onClickItem: (MangaSource) -> Unit,
onLongClickItem: (MangaSource) -> Unit,
onClickMenu: (MangaSource) -> Unit,
@ -172,6 +173,6 @@ private fun SourcePinButton(
}
sealed interface SourceUiModel {
data class Item(val source: MangaSource) : SourceUiModel
data class Item(val source: MangaParserSource) : SourceUiModel
data class Header(val language: String?) : SourceUiModel
}

@ -1,8 +1,10 @@
package org.xtimms.shirizu.sections.explore.sources
import android.content.Context
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -13,12 +15,17 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.MangaSourceInfo
import org.xtimms.shirizu.core.model.getSummary
import org.xtimms.shirizu.core.model.getTitle
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
import org.xtimms.shirizu.utils.LocaleHelper
import javax.inject.Inject
class SourcesScreenModel @Inject constructor(
@ApplicationContext context: Context,
private val mangaSourcesRepository: MangaSourcesRepository,
) : StateScreenModel<SourcesScreenModel.State>(State()) {
@ -36,7 +43,7 @@ class SourcesScreenModel @Inject constructor(
}
}
private fun collectEnabledSources(sources: List<MangaSource>) {
private fun collectEnabledSources(sources: List<MangaParserSource>) {
mutableState.update { state ->
state.copy(
isLoading = false,

@ -73,12 +73,7 @@ class HistoryScreenModel @Inject constructor(
val searchQuery = query ?: ""
history.asSequence().map { it }
.filter { it.manga.isNsfw == nsfw }
.sortedByDescending {
when (sort) {
SortOption.DATE_ADDED -> it.history.updatedAt
SortOption.ALPHABETICAL -> it.manga.title.lowercase()
}.toString()
}
.sortedWith(MangaComparator(sort))
.filter(queryFilter(searchQuery)).toList()
.toImmutableList()
}.collectLatest {
@ -222,10 +217,7 @@ class HistoryScreenModel @Inject constructor(
val searchQuery: String? = null,
val selection: PersistentList<Manga> = persistentListOf(),
val showNsfw: Boolean = AppSettings.showNsfwInHistory(),
val availableSorts: List<SortOption> = listOf(
SortOption.DATE_ADDED,
SortOption.ALPHABETICAL
),
val availableSorts: List<SortOption> = SortOption.entries,
val sort: SortOption = SortOption.ALPHABETICAL,
val list: PersistentList<HistoryItemModel> = persistentListOf(),
val dialog: Dialog? = null,
@ -265,3 +257,13 @@ class HistoryScreenModel @Inject constructor(
}
}
private class MangaComparator(private val sort: SortOption) : Comparator<MangaWithHistory> {
override fun compare(o1: MangaWithHistory, o2: MangaWithHistory): Int {
return when (sort) {
SortOption.DATE_ADDED -> o2.history.updatedAt.compareTo(o1.history.updatedAt)
SortOption.ALPHABETICAL -> o1.manga.title.compareTo(o2.manga.title)
SortOption.PROGRESS -> o2.history.percent.compareTo(o1.history.percent)
}
}
}

@ -36,6 +36,7 @@ import org.xtimms.shirizu.core.components.DialogCheckBoxItem
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.LibraryBottomActionMenu
import org.xtimms.shirizu.core.components.ShirizuDialog
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.core.ui.screens.TabContent
import org.xtimms.shirizu.sections.details.DetailsScreen
@ -80,7 +81,7 @@ fun Screen.historyTab(): TabContent {
onToggleEnableNsfw = { screenModel.filterNsfw(it) },
onFilterChanged = { screenModel.search(it) },
onSortSelected = { screenModel.sort(it) },
onClick = { navigator.push(DetailsScreen(it.manga)) },
onClick = { navigator.push(DetailsScreen(it.manga.id)) },
onHistorySelected = screenModel::toggleSelection
)
}

@ -1,6 +1,7 @@
package org.xtimms.shirizu.sections.library.history
enum class SortOption {
ALPHABETICAL,
DATE_ADDED,
enum class SortOption(id: Int) {
ALPHABETICAL(0),
DATE_ADDED(1),
PROGRESS(2)
}

@ -26,6 +26,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.sections.details.DetailsScreen
import org.xtimms.shirizu.utils.lang.Screen
@ -75,7 +76,7 @@ data class MangaListScreen(private val source: MangaSource) : Screen() {
columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onMangaClick = { navigator.push((DetailsScreen(it, true))) },
onMangaClick = { navigator.push((DetailsScreen(it.id, true))) },
onMangaLongClick = { manga -> },
)
}

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.parser.MangaRepository
@ -25,7 +26,7 @@ class MangaListScreenModel @AssistedInject constructor(
mangaRepositoryFactory: MangaRepository.Factory,
) : StateScreenModel<MangaListScreenModel.State>(State()) {
val source = MangaSource.valueOf(sourceName)
val source = MangaParserSource.valueOf(sourceName)
private val repository = mangaRepositoryFactory.create(source)
private val hasNextPage = MutableStateFlow(false)
private val mangaList = MutableStateFlow<List<Manga>?>(null)

@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import kotlinx.collections.immutable.persistentListOf
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.AppBar
@ -21,7 +22,7 @@ import org.xtimms.shirizu.core.components.SearchToolbar
fun BrowseSourceToolbar(
searchQuery: String?,
onSearchQueryChange: (String?) -> Unit,
source: MangaSource?,
source: MangaParserSource?,
navigateUp: () -> Unit,
onWebViewClick: () -> Unit,
onSearch: (String) -> Unit,

@ -0,0 +1,114 @@
package org.xtimms.shirizu.sections.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.HorizontalDivider
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 androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.components.PreferenceItem
import org.xtimms.shirizu.core.components.PreferenceSubtitle
import org.xtimms.shirizu.sections.shelf.ShelfCategory
import org.xtimms.shirizu.sections.stats.ChaptersChart
import org.xtimms.shirizu.sections.stats.TimeCard
import org.xtimms.shirizu.sections.stats.categories.CategoriesChart
import org.xtimms.shirizu.ui.theme.ShirizuTheme
import org.xtimms.shirizu.utils.composable.bodyWidth
@Composable
fun ProfileScreen(
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.bodyWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
AsyncImage(
model = "https://avatars.githubusercontent.com/u/61558546?v=4",
contentDescription = "profile",
modifier = Modifier
.padding(16.dp)
.clip(RoundedCornerShape(100))
.size(100.dp)
)
}
item {
Text(text = "Xtimms", style = MaterialTheme.typography.titleLarge)
}
item {
Text(text = "My status", style = MaterialTheme.typography.bodyMedium)
}
item {
HorizontalDivider(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp))
}
item {
PreferenceSubtitle(text = stringResource(id = R.string.statistics))
}
item {
TimeCard(
modifier = Modifier
.height(IntrinsicSize.Min)
.padding(horizontal = 16.dp)
)
}
item {
CategoriesChart(
modifier = Modifier.padding(16.dp),
categories = listOf(
ShelfCategory(1, "Test 1", 3),
ShelfCategory(2, "Test 2", 4),
ShelfCategory(3, "Test 3", 6),
ShelfCategory(4, "Test 4", 7),
ShelfCategory(5, "Test 5", 13),
ShelfCategory(6, "Test 6", 12),
)
)
}
item {
PreferenceSubtitle(text = stringResource(id = R.string.menu))
}
item {
PreferenceItem(
icon = Icons.Outlined.Settings,
title = stringResource(id = R.string.settings)
)
}
item {
PreferenceItem(
icon = Icons.AutoMirrored.Outlined.HelpOutline,
title = stringResource(id = R.string.help_centre)
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun ProfileScreenPreview() {
ShirizuTheme {
ProfileScreen()
}
}

@ -0,0 +1,48 @@
package org.xtimms.shirizu.sections.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.tab.TabOptions
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.utils.composable.bodyWidth
import org.xtimms.shirizu.utils.lang.Tab
object ProfileTab : Tab {
private val snackbarHostState = SnackbarHostState()
override val options: TabOptions
@Composable
get() {
val image = Icons.Outlined.AccountCircle
return TabOptions(
index = 3u,
title = stringResource(R.string.profile),
icon = rememberVectorPainter(image),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
Scaffold(
snackbarHost = { snackbarHostState }
) {
ProfileScreen()
}
}
}

@ -32,10 +32,9 @@ import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.network.MangaHttpClient
import org.xtimms.shirizu.core.network.interceptors.ImageProxyInterceptor
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.RemoteMangaRepository
import org.xtimms.shirizu.core.parser.ParserMangaRepository
import org.xtimms.shirizu.core.parser.local.isFileUri
import org.xtimms.shirizu.core.parser.local.isZipUri
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.sections.reader.pager.ReaderPage
import org.xtimms.shirizu.utils.FileSize
import org.xtimms.shirizu.utils.RetainedLifecycleCoroutineScope
@ -81,7 +80,7 @@ class PageLoader @Inject constructor(
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository
return repository is ParserMangaRepository
// && settings.isPagesPreloadEnabled
&& !context.isPowerSaveMode()
&& !isLowRam()

@ -2,10 +2,13 @@ package org.xtimms.shirizu.sections.reader.pager
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.parcelable.MangaSourceParceler
@Parcelize
@TypeParceler<MangaSource, MangaSourceParceler>
data class ReaderPage(
val id: Long,
val url: String,

@ -1,8 +1,6 @@
package org.xtimms.shirizu.sections.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@ -42,6 +40,7 @@ import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.MangaCarouselWithHeader
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.icons.Dice
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.sections.details.DetailsScreen
import org.xtimms.shirizu.sections.search.global.GlobalSearchScreen
import org.xtimms.shirizu.sections.suggestions.SuggestionsScreen
@ -57,13 +56,13 @@ object SearchTab : Tab {
get() {
val image = Icons.Outlined.Search
return TabOptions(
index = 4u,
index = 2u,
title = stringResource(R.string.search),
icon = rememberVectorPainter(image),
)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
@ -135,7 +134,7 @@ object SearchTab : Tab {
MangaCarouselWithHeader(
items = state.list,
title = stringResource(id = R.string.suggestions),
onItemClick = { navigator.push(DetailsScreen(it)) },
onItemClick = { navigator.push(DetailsScreen(it.id)) },
onMoreClick = { navigator.push(SuggestionsScreen) },
refreshing = state.isLoading,
modifier = Modifier.animateItem(),

@ -81,11 +81,6 @@ class GlobalSearchScreenModel @Inject constructor(
} else {
null
}
val sources = if (SearchSuggestionType.SOURCES in types) {
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
} else {
null
}
val tags = tagsDeferred?.await()
val mangaList = mangaDeferred?.await()
@ -93,11 +88,10 @@ class GlobalSearchScreenModel @Inject constructor(
val hints = hintsDeferred?.await()
val authors = authorsDeferred?.await()
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
buildList(queries.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
if (!mangaList.isNullOrEmpty()) {
add(SearchSuggestionItem.MangaList(mangaList))
}
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) }
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }

@ -2,6 +2,7 @@ package org.xtimms.shirizu.sections.search.global.model
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.ListModel
@ -44,7 +45,7 @@ sealed interface SearchSuggestionItem : ListModel {
}
data class Source(
val source: MangaSource,
val source: MangaParserSource,
val isEnabled: Boolean,
) : SearchSuggestionItem {

@ -1,101 +0,0 @@
package org.xtimms.shirizu.sections.settings.sources
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.NoAdultContent
import androidx.compose.material.icons.outlined.SettingsApplications
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.PreferenceItem
import org.xtimms.shirizu.core.components.PreferenceSwitch
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.NSFW
const val SOURCES_DESTINATION = "sources"
@Composable
fun SourcesView(
viewModel: SourcesSettingsViewModel = hiltViewModel(),
navigateBack: () -> Unit,
navigateToSourcesCatalog: () -> Unit,
navigateToSourcesManagement: () -> Unit,
) {
val context = LocalContext.current
val availableSourcesCount = viewModel.availableSourcesCount.collectAsState(-1).value
val enabledSourcesCount = viewModel.enabledSourcesCount.collectAsState(-1).value
val state by viewModel.viewStateFlow.collectAsStateWithLifecycle()
var isNSFWEnabled by remember {
mutableStateOf(AppSettings.isNSFWEnabled())
}
ScaffoldWithTopAppBar(
title = stringResource(R.string.manga_sources),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
) {
item {
PreferenceItem(
title = stringResource(id = R.string.manage_sources),
description = if (enabledSourcesCount >= 0) {
context.resources.getQuantityString(
R.plurals.items,
enabledSourcesCount,
enabledSourcesCount
)
} else {
null
},
icon = Icons.Outlined.SettingsApplications,
onClick = { navigateToSourcesManagement() }
)
}
item {
PreferenceItem(
title = stringResource(id = R.string.sources_catalog),
description = if (availableSourcesCount >= 0) {
stringResource(R.string.available_d, availableSourcesCount)
} else {
null
},
icon = Icons.Outlined.Apps,
onClick = { navigateToSourcesCatalog() }
)
}
item {
PreferenceSwitch(
title = stringResource(id = R.string.disable_nsfw),
description = stringResource(id = R.string.disable_nsfw_desc),
icon = Icons.Outlined.NoAdultContent,
isChecked = isNSFWEnabled
) {
isNSFWEnabled = !isNSFWEnabled
AppSettings.updateValue(NSFW, isNSFWEnabled)
}
}
}
}
}

@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.parser.favicon.faviconUri
@ -23,7 +24,7 @@ import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable
fun SourceCatalogItem(
source: MangaSource,
source: MangaParserSource,
) {
Row(
@ -53,6 +54,6 @@ fun SourceCatalogItem(
@Composable
fun SourceCatalogItemPreview() {
ShirizuTheme {
SourceCatalogItem(source = MangaSource.MANGADEX)
SourceCatalogItem(source = MangaParserSource.MANGADEX)
}
}

@ -1,90 +0,0 @@
package org.xtimms.shirizu.sections.settings.sources.catalog
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SearchOff
import androidx.room.InvalidationTracker
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.model.ContentType
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.database.TABLE_SOURCES
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.database.removeObserverAsync
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
import org.xtimms.shirizu.utils.lang.lifecycleScope
class SourcesCatalogListProducer @AssistedInject constructor(
@Assisted private val locale: String?,
@Assisted private val contentType: ContentType,
@Assisted lifecycle: ViewModelLifecycle,
private val repository: MangaSourcesRepository,
private val database: ShirizuDatabase,
) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
private val scope = lifecycle.lifecycleScope
private var query: String? = null
val list = MutableStateFlow(emptyList<SourceCatalogItemModel>())
private var job = scope.launch(Dispatchers.Default) {
list.value = buildList()
}
init {
scope.launch(Dispatchers.Default) {
database.invalidationTracker.addObserver(this@SourcesCatalogListProducer)
}
lifecycle.addOnClearedListener(this)
}
override fun onCleared() {
database.invalidationTracker.removeObserverAsync(this)
}
override fun onInvalidated(tables: Set<String>) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob.cancelAndJoin()
list.update { buildList() }
}
}
fun setQuery(value: String?) {
this.query = value
onInvalidated(emptySet())
}
private suspend fun buildList(): List<SourceCatalogItemModel> {
val sources = repository.getDisabledSources().toMutableList()
when (val q = query) {
null -> sources.retainAll { it.contentType == contentType && it.locale == locale }
"" -> return emptyList()
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
}
sources.sortBy { it.title }
return sources.map {
SourceCatalogItemModel(
source = it,
showSummary = query != null,
)
}
}
@AssistedFactory
interface Factory {
fun create(
locale: String?,
contentType: ContentType,
lifecycle: ViewModelLifecycle,
): SourcesCatalogListProducer
}
}

@ -57,11 +57,11 @@ fun SourcesCatalogPager(
items(
items = sources,
) { item ->
item.items.forEach { source ->
/*item.items.forEach { source ->
SourceCatalogItem(
source = source.source,
)
}
}*/
}
}
}

@ -1,55 +0,0 @@
package org.xtimms.shirizu.sections.settings.sources.catalog
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import kotlinx.coroutines.launch
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
const val CATALOG_DESTINATION = "catalog"
@Composable
fun SourcesCatalogView(
coil: ImageLoader,
sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(),
navigateBack: () -> Unit,
) {
val categories by sourcesCatalogViewModel.content.collectAsStateWithLifecycle(emptyList())
ScaffoldWithClassicTopAppBar(
title = stringResource(R.string.sources_catalog),
navigateBack = navigateBack
) { padding ->
Column(
modifier = Modifier.padding(padding)
) {
val pagerState = rememberPagerState(0) { categories.size }
val scope = rememberCoroutineScope()
if (categories.isNotEmpty()) {
SourcesCatalogTabs(
categories = categories,
pagerState = pagerState,
) { scope.launch { pagerState.animateScrollToPage(it) } }
}
SourcesCatalogPager(
coil = coil,
state = pagerState,
contentPadding = padding,
searchQuery = null,
getSourcesForPage = { categories }
)
}
}
}

@ -1,87 +0,0 @@
package org.xtimms.shirizu.sections.settings.sources.catalog
import androidx.annotation.MainThread
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
import org.xtimms.shirizu.utils.ReversibleAction
import org.xtimms.shirizu.utils.lang.MutableEventFlow
import org.xtimms.shirizu.utils.lang.call
import java.util.EnumMap
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class SourcesCatalogViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
private val listProducerFactory: SourcesCatalogListProducer.Factory,
) : KotatsuBaseViewModel() {
private val lifecycle = RetainedLifecycleImpl()
private var searchQuery: String? = null
val onActionDone = MutableEventFlow<ReversibleAction>()
val locales = repository.allMangaSources.mapToSet { it.locale }
val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
private val listProducers = locale.map { lc ->
createListProducers(lc)
}.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
@OptIn(ExperimentalCoroutinesApi::class)
val content: StateFlow<List<SourceCatalogPage>> = listProducers.flatMapLatest {
val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } }
combine<SourceCatalogPage, List<SourceCatalogPage>>(flows, Array<SourceCatalogPage>::toList)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
override fun onCleared() {
super.onCleared()
lifecycle.dispatchOnCleared()
}
fun performSearch(query: String?) {
searchQuery = query
listProducers.value.forEach { (_, v) -> v.setQuery(query) }
}
fun setLocale(value: String?) {
locale.value = value
}
fun addSource(source: MangaSource) {
launchJob(Dispatchers.Default) {
val rollback = repository.setSourceEnabled(source, true)
onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
}
}
@MainThread
private fun createListProducers(lc: String?): Map<ContentType, SourcesCatalogListProducer> {
val types = EnumSet.allOf(ContentType::class.java)
if (AppSettings.isNSFWEnabled()) {
types.remove(ContentType.HENTAI)
}
return types.associateWithTo(EnumMap(ContentType::class.java)) { type ->
listProducerFactory.create(lc, type, lifecycle).also {
it.setQuery(searchQuery)
}
}
}
}

@ -25,6 +25,7 @@ import cafe.adriel.voyager.navigator.tab.TabOptions
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.LibraryBottomActionMenu
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.sections.details.DetailsScreen
@ -85,7 +86,7 @@ object ShelfTab : Tab, NoLiftingAppBarScreen {
currentPage = { screenModel.activeCategoryIndex },
hasActiveFilters = state.hasActiveFilters,
onChangeCurrentPage = { },
onMangaClicked = { navigator.push(DetailsScreen(it)) },
onMangaClicked = { navigator.push(DetailsScreen(it.id)) },
onToggleSelection = { },
onToggleRangeSelection = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)

@ -18,6 +18,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.ui.theme.colorMax
import org.xtimms.shirizu.ui.theme.colorMin
@ -158,12 +159,12 @@ private fun PreviewChart() {
ChaptersChart(
modifier = Modifier.size(100.dp),
chapters = listOf(
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY)
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY)
),
chartPadding = PaddingValues(vertical = 16.dp)
)

@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.ui.theme.colorMax
import org.xtimms.shirizu.ui.theme.colorMin
@ -98,12 +99,12 @@ fun MinMaxReadCard(
.fillMaxHeight()
.fillMaxWidth(),
chapters = listOf(
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaSource.DUMMY)
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY),
MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY)
)
)
}

@ -14,13 +14,28 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.R
import org.xtimms.shirizu.sections.shelf.ShelfCategory
import org.xtimms.shirizu.utils.material.HarmonizedColorPalette
import org.xtimms.shirizu.utils.material.combineColors
import org.xtimms.shirizu.utils.material.harmonize
import org.xtimms.shirizu.utils.material.harmonizeWithColor
import org.xtimms.shirizu.utils.material.toPalette
import java.math.BigDecimal
data class TagUsage(
val name: String,
val mangaCount: BigDecimal,
var color: HarmonizedColorPalette? = null
)
var baseColors = listOf(
Color(0xFFF86BAE),
@ -35,10 +50,13 @@ var baseColors = listOf(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CategoriesChart(
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
categories: List<ShelfCategory>
) {
val isNightMode = isSystemInDarkTheme()
val labelWithoutTag = stringResource(R.string.progress)
val maxDisplay = 7
val colors = baseColors.map {
toPalette(
@ -48,9 +66,61 @@ fun CategoriesChart(
),
)
}
val restColor = toPalette(
color = harmonize(
designColor = Color(0xFF222222),
sourceColor = MaterialTheme.colorScheme.primary
),
).copy(
main = if (isNightMode) Color(0xFFF0F0F0) else Color(0xFF222222),
onSurface = if (isNightMode) Color(0xFF1A1A1A) else Color(0xFFF4F4F4)
)
val stubColor = toPalette(
color = harmonize(
designColor = Color(0xFFCCCCCC),
sourceColor = MaterialTheme.colorScheme.primary
),
).copy(
main = if (isNightMode) MaterialTheme.colorScheme.surfaceVariant else Color(0xFFCCCCCC),
)
var offsetColor = 0
val tags by remember {
var result = categories
.groupBy { it.title.trim() }
.map { tag ->
TagUsage(
tag.key,
tag.value.map { it.mangaCount.toBigDecimal() }.reduce { acc, next -> acc + next },
)
}
.sortedBy { it.name }
.reversed()
.toList()
// Set colors
result.subList(0, result.size.coerceAtMost(maxDisplay)).forEachIndexed { index, tagUsage ->
tagUsage.color = colors.getOrNull(index - offsetColor) ?: colors.last()
}
// Combine rest tags to one
if (result.size > maxDisplay) {
result = result.slice(0..<maxDisplay) + TagUsage(
name = labelWithoutTag,
mangaCount = result
.slice(maxDisplay until result.size)
.map { it.mangaCount }
.reduce { acc, next -> acc + next },
color = restColor,
)
}
mutableStateOf(result)
}
Card(
modifier = modifier,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(
containerColor = combineColors(
@ -60,25 +130,21 @@ fun CategoriesChart(
),
)
) {
Box {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
DonutChart(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
.size(64.dp),
items = emptyList(),
DonutChart(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
.size(64.dp),
items = tags,
)
FlowRow(Modifier.padding(4.dp, 4.dp)) {
tags.forEach { tag ->
TagAmount(
modifier = Modifier.padding(4.dp, 4.dp),
value = tag.name,
palette = tag.color,
amount = tag.mangaCount
)
FlowRow(Modifier.padding(4.dp, 4.dp)) {
}
}
}
}
}

@ -16,11 +16,12 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.sections.shelf.ShelfCategory
import java.math.RoundingMode
@Composable
fun DonutChart(
modifier: Modifier = Modifier,
items: List<ShelfCategory>,
items: List<TagUsage>,
chartPadding: PaddingValues = PaddingValues(0.dp),
) {
val localDensity = LocalDensity.current
@ -42,7 +43,7 @@ fun DonutChart(
val heightWithPaddings = height - topOffset - bottomOffset
val widthWithPaddings = width - startOffset - endOffset
val total = items.map { it.id }.reduce { acc, next -> acc + next }
val total = items.map { it.mangaCount }.reduce { acc, next -> acc + next }
var offset = 0f
val gap = 0f
@ -52,11 +53,37 @@ fun DonutChart(
val minSweepAngle = 28f
val offsetAngle = -90f
var itemAngles = items.map {
it.mangaCount
.divide(total, 5, RoundingMode.HALF_DOWN)
.multiply(360.toBigDecimal())
.toFloat()
}
val shareAngle = itemAngles
.filter { it < minSweepAngle }
.map { minSweepAngle - it }
.fold(0f) { acc, next -> acc + next }
val splitItems = itemAngles.filter { it > minSweepAngle }.toMutableList()
itemAngles = itemAngles.map { angle ->
if (angle < minSweepAngle) {
return@map minSweepAngle
}
if (angle > minSweepAngle) {
return@map angle - shareAngle / splitItems.size
}
angle
}
items.forEachIndexed { index, tag ->
val sweepAngle = itemAngles[index]
drawArc(
Color.Black,
tag.color?.main ?: Color.Black,
startAngle = offset + halfGap + offsetAngle,
sweepAngle = 36 - gap,
sweepAngle = sweepAngle - gap,
useCenter = false,
topLeft = Offset(startOffset + halfStrokeWidth, topOffset + halfStrokeWidth),
size = Size(widthWithPaddings - strokeWidth, heightWithPaddings - strokeWidth),
@ -66,7 +93,7 @@ fun DonutChart(
),
)
offset += 50
offset += sweepAngle
}
}
}

@ -49,7 +49,7 @@ fun TagAmount(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "56",
text = amount.toString(),
softWrap = false,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.W900),
)

@ -41,6 +41,7 @@ import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.MangaGridItem
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.components.icons.Creation
import org.xtimms.shirizu.core.model.parcelable.ParcelableManga
import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.sections.details.DetailsScreen
@ -84,7 +85,7 @@ object SuggestionsScreen : Screen() {
SuggestionsScreenContent(
suggestions = it,
contentPadding = padding,
onClick = { suggestion -> navigator.push(DetailsScreen(suggestion.manga)) }
onClick = { suggestion -> navigator.push(DetailsScreen(suggestion.manga.id)) }
)
}
}

@ -2,6 +2,9 @@ package org.xtimms.shirizu.utils
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> {
@ -40,4 +43,17 @@ class MultiMutex<T : Any> : Set<T> {
delegates.remove(element)?.unlock()
}
}
@OptIn(ExperimentalContracts::class)
suspend inline fun <R> withLock(element: T, block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return try {
lock(element)
block()
} finally {
unlock(element)
}
}
}

@ -5,6 +5,7 @@ import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.IOException
import org.json.JSONObject
@ -72,3 +73,5 @@ private fun Char.isValidForHeaderValue(): Boolean {
// from okhttp3.Headers$Companion.checkValue
return this == '\t' || this in '\u0020'..'\u007e'
}
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }

@ -1,12 +1,22 @@
package org.xtimms.shirizu.utils.system
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.shirizu.R
import java.util.Locale
fun String.toLocale() = Locale(this)
fun Locale?.getDisplayName(context: Context): String = when (this) {
null -> context.getString(R.string.multi_lang)
Locale.ROOT -> context.getString(R.string.various_languages)
else -> getDisplayLanguage(this).toTitleCase(this)
}
fun LocaleListCompat.toList(): List<Locale> = List(size()) { i -> getOrThrow(i) }
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()

@ -356,9 +356,16 @@
<string name="shelves">Shelves</string>
<string name="history_cleared">History cleared</string>
<string name="search_by_reading_history">Search by reading history</string>
<string name="sort_alphabetically">A-Z</string>
<string name="sort_alphabetically">Name</string>
<string name="sort_date_added">Date added</string>
<string name="show_nsfw">Show NSFW</string>
<string name="action_save">Save</string>
<string name="action_mark_as_completed">Mark as completed</string>
<string name="sort_alphabetically_reversed">Z-A</string>
<string name="profile">Profile</string>
<string name="menu">Menu</string>
<string name="help_centre">Help centre</string>
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="external_source">External/plugin</string>
<string name="various_languages">Various languages</string>
</resources>
Loading…
Cancel
Save