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" /> <option name="frameScreenshot" value="true" />
</component> </component>
<component name="direct_access_persist.xml"> <component name="direct_access_persist.xml">
<option name="deviceSelectionList"> <option name="selectedCloudProject" />
<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" />
</component> </component>
</project> </project>

@ -173,7 +173,7 @@ dependencies {
kapt("com.google.dagger:hilt-compiler:2.51.1") kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-work:1.2.0") implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler: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") exclude(group = "org.json", module = "json")
} }
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0") implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 4, "version": 4,
"identityHash": "dbe1dcac0f49c5ae2ac88d88aa280081", "identityHash": "90b73386d5c61c2ddf46d6354ca2f1b6",
"entities": [ "entities": [
{ {
"tableName": "manga", "tableName": "manga",
@ -199,7 +199,7 @@
}, },
{ {
"tableName": "sources", "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": [ "fields": [
{ {
"fieldPath": "source", "fieldPath": "source",
@ -218,6 +218,24 @@
"columnName": "sort_key", "columnName": "sort_key",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "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": { "primaryKey": {
@ -851,7 +869,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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 <uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE" android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> 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> <queries>
<intent> <intent>

@ -86,6 +86,7 @@ import org.xtimms.shirizu.sections.feed.FeedScreen
import org.xtimms.shirizu.sections.history.HistoryTab import org.xtimms.shirizu.sections.history.HistoryTab
import org.xtimms.shirizu.sections.library.LibraryTab import org.xtimms.shirizu.sections.library.LibraryTab
import org.xtimms.shirizu.sections.onboarding.OnboardingScreen 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.search.SearchTab
import org.xtimms.shirizu.sections.settings.SettingsScreen import org.xtimms.shirizu.sections.settings.SettingsScreen
import org.xtimms.shirizu.sections.shelf.ShelfTab import org.xtimms.shirizu.sections.shelf.ShelfTab
@ -301,7 +302,6 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
object MainScreen : Screen() { object MainScreen : Screen() {
private val librarySearchEvent = Channel<String>()
private val openTabEvent = Channel<Tab>() private val openTabEvent = Channel<Tab>()
private val showBottomNavEvent = Channel<Boolean>() private val showBottomNavEvent = Channel<Boolean>()
@ -312,7 +312,8 @@ object MainScreen : Screen() {
// ShelfTab, // ShelfTab,
// HistoryTab, // HistoryTab,
ExploreTab(), ExploreTab(),
SearchTab SearchTab,
// ProfileTab
) )
@Composable @Composable
@ -331,13 +332,6 @@ object MainScreen : Screen() {
actions = { actions = {
AppBarActions( AppBarActions(
persistentListOf( persistentListOf(
AppBar.Action(
title = stringResource(R.string.suggestions),
icon = Icons.Outlined.Creation,
onClick = {
navigator.push(SuggestionsScreen)
},
),
AppBar.Action( AppBar.Action(
title = stringResource(R.string.feed), title = stringResource(R.string.feed),
icon = Icons.Outlined.RssFeed, icon = Icons.Outlined.RssFeed,
@ -425,6 +419,7 @@ object MainScreen : Screen() {
// is Tab.History -> HistoryTab // is Tab.History -> HistoryTab
is Tab.Explore -> ExploreTab() is Tab.Explore -> ExploreTab()
is Tab.Search -> SearchTab is Tab.Search -> SearchTab
// is Tab.Profile -> ProfileTab
} }
} }
} }
@ -508,5 +503,6 @@ object MainScreen : Screen() {
// data object History : Tab // data object History : Tab
data object Explore : Tab data object Explore : Tab
data object Search : 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 androidx.collection.LruCache
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import org.xtimms.shirizu.core.cache.MemoryContentCache.Key as CacheKey
class ExpiringLruCache<T>( class ExpiringLruCache<T>(
val maxSize: Int, val maxSize: Int,
private val lifetime: Long, private val lifetime: Long,
private val timeUnit: TimeUnit, 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 val value = cache[key] ?: return null
if (value.isExpired) { if (value.isExpired) {
cache.remove(key) cache.remove(key)
@ -19,7 +22,7 @@ class ExpiringLruCache<T>(
return value.get() 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)) cache.put(key, ExpiringValue(value, lifetime, timeUnit))
} }
@ -30,4 +33,8 @@ class ExpiringLruCache<T>(
fun trimToSize(size: Int) { fun trimToSize(size: Int) {
cache.trimToSize(size) 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.utils.system.isLowRamDevice
import java.util.concurrent.TimeUnit 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 { init {
application.registerComponentCallbacks(this) application.registerComponentCallbacks(this)
} }
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES) suspend fun getDetails(source: MangaSource, url: String): Manga? {
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES) return detailsCache[Key(source, url)]?.awaitOrNull()
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES) }
override val isCachingEnabled: Boolean = true
override suspend fun getDetails(source: MangaSource, url: String): Manga? { fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull() detailsCache[Key(source, url)] = details
} }
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) { suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
detailsCache[ContentCache.Key(source, url)] = details return pagesCache[Key(source, url)]?.awaitOrNull()
} }
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? { fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull() pagesCache[Key(source, url)] = pages
} }
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) { suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
pagesCache[ContentCache.Key(source, url)] = pages return relatedMangaCache[Key(source, url)]?.awaitOrNull()
} }
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? { fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull() relatedMangaCache[Key(source, url)] = related
} }
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) { fun clear(source: MangaSource) {
relatedMangaCache[ContentCache.Key(source, url)] = related clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
} }
override fun onConfigurationChanged(newConfig: Configuration) = Unit override fun onConfigurationChanged(newConfig: Configuration) = Unit
@ -67,4 +79,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
else -> cache.trimToSize(cache.maxSize / 2) 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 @Composable
fun RowScope.ReadButton( fun RowScope.ReadButton(
info: HistoryInfo, info: HistoryInfo?,
estimatedReadTime: String estimatedReadTime: String
) { ) {
@ -63,13 +63,13 @@ fun RowScope.ReadButton(
val animatedCardContainerColor = animateColorAsState( val animatedCardContainerColor = animateColorAsState(
label = "animatedCardContainerColor", 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) animationSpec = TweenSpec(500)
).value ).value
val animatedCardContentColor = animateColorAsState( val animatedCardContentColor = animateColorAsState(
label = "animatedCardContentColor", 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) animationSpec = TweenSpec(500)
).value ).value
@ -105,9 +105,9 @@ fun RowScope.ReadButton(
contentAlignment = Alignment.CenterEnd, contentAlignment = Alignment.CenterEnd,
) { ) {
BackgroundProgress( BackgroundProgress(
if (info.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, if (info?.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
if (!info.isValid) 0.1f else 0.33f, if (info?.isValid == true) 0.1f else 0.33f,
info.history?.percent?.coerceIn(0f, 1f) ?: 0f info?.history?.percent?.coerceIn(0f, 1f) ?: 0f
) )
Column( Column(
modifier = Modifier modifier = Modifier
@ -143,7 +143,8 @@ fun RowScope.ReadButton(
animationSpec = infiniteRepeatable(tween(15000), RepeatMode.Restart) animationSpec = infiniteRepeatable(tween(15000), RepeatMode.Restart)
) )
val chaptersSubtitle = when { val chaptersSubtitle = when {
!info.isValid -> stringResource(R.string.loading_) info == null -> "null"
info.isValid -> stringResource(R.string.loading_)
info.currentChapter >= 0 -> when (infiniteTransition) { info.currentChapter >= 0 -> when (infiniteTransition) {
1 -> stringResource( 1 -> stringResource(
R.string.chapter_d_of_d, R.string.chapter_d_of_d,
@ -162,7 +163,7 @@ fun RowScope.ReadButton(
) )
} }
Text( Text(
text = if (info.history != null) { text = if (info?.history != null) {
stringResource(R.string.continue_reading) stringResource(R.string.continue_reading)
} else { } else {
stringResource(R.string.read) stringResource(R.string.read)

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

@ -2,10 +2,15 @@ package org.xtimms.shirizu.core.components
import android.content.res.Resources import android.content.res.Resources
import androidx.compose.foundation.layout.ColumnScope 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.DropdownMenuItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
@ -35,5 +40,12 @@ internal fun ColumnScope.SortDropdownMenuContent(
internal fun SortOption.label(resources: Resources): String = when (this) { internal fun SortOption.label(resources: Resources): String = when (this) {
SortOption.ALPHABETICAL -> resources.getString(R.string.sort_alphabetically) 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) 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 androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder
@ -65,6 +66,9 @@ abstract class MangaSourcesDao {
source = source, source = source,
isEnabled = isEnabled, isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1, sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
) )
upsert(entity) upsert(entity)
} }

@ -12,4 +12,7 @@ data class MangaSourceEntity(
val source: String, val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean, @ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int, @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 androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter 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.MangaSource
import org.xtimms.shirizu.utils.system.iterator import org.xtimms.shirizu.utils.system.iterator
import java.text.DecimalFormat import java.text.DecimalFormat
@ -61,4 +62,4 @@ fun MangaChapter.formatNumber(): String? {
} }
val Manga.isLocal: Boolean val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL get() = source == LocalMangaSource

@ -1,13 +1,72 @@
package org.xtimms.shirizu.core.model 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.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource 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 { data object LocalMangaSource : MangaSource {
MangaSource.entries.forEach { 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 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) { override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title) parcel.writeString(title)
parcel.writeString(key) parcel.writeString(key)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

@ -30,7 +30,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags) parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state) parcel.writeSerializable(state)
parcel.writeString(author) parcel.writeString(author)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
override fun create(parcel: Parcel) = ParcelableManga( 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.BuildConfig
import org.xtimms.shirizu.core.network.CommonHeaders import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.parser.MangaRepository 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 java.net.IDN
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -26,7 +26,7 @@ class CommonHeadersInterceptor @Inject constructor(
val request = chain.request() val request = chain.request()
val source = request.tag(MangaSource::class.java) val source = request.tag(MangaSource::class.java)
val repository = if (source != null) { val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}") 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 @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
private val db: ShirizuDatabase, private val db: ShirizuDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
) { ) {
suspend fun findMangaById(mangaId: Long): Manga? { suspend fun findMangaById(mangaId: Long): Manga? {
@ -23,6 +24,13 @@ class MangaDataRepository @Inject constructor(
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga() 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) { suspend fun storeManga(manga: Manga) {
db.withTransaction { db.withTransaction {
val tags = manga.tags.toEntities() 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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread import androidx.annotation.MainThread
@ -10,8 +11,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient 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.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents 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.network.cookies.MutableCookieJar
import org.xtimms.shirizu.core.prefs.SourceSettings import org.xtimms.shirizu.core.prefs.SourceSettings
import org.xtimms.shirizu.utils.system.configureForParser 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.sanitizeHeaderValue
import org.xtimms.shirizu.utils.system.toList import org.xtimms.shirizu.utils.system.toList
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@ -72,6 +79,30 @@ class MangaLoaderContextImpl @Inject constructor(
return LocaleListCompat.getAdjustedDefault().toList() 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 @MainThread
private fun obtainWebView(): WebView { private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also { 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.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser 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) return loaderContext.newParserInstance(source)
} }

@ -1,21 +1,28 @@
package org.xtimms.shirizu.core.parser package org.xtimms.shirizu.core.parser
import android.content.Context
import androidx.annotation.AnyThread 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.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage 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.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder 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 org.xtimms.shirizu.core.parser.local.LocalMangaRepository
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -30,6 +37,8 @@ interface MangaRepository {
val contentRatings: Set<ContentRating> val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean val isTagsExclusionSupported: Boolean
@ -50,30 +59,58 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga> 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 @Singleton
class Factory @Inject constructor( class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, 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 @AnyThread
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) { when (source) {
return localMangaRepository is MangaSourceInfo -> return create(source.mangaSource)
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
} }
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository( val repository = createRepository(source)
parser = MangaParser(source, loaderContext), 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 = contentCache,
) )
cache[source] = WeakReference(repository) } else {
repository EmptyMangaRepository(source)
} }
else -> null
} }
} }
} }

@ -1,7 +1,6 @@
package org.xtimms.shirizu.core.parser package org.xtimms.shirizu.core.parser
import android.util.Log import android.util.Log
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.BuildConfig 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.cache.SafeDeferred
import org.xtimms.shirizu.core.prefs.SourceSettings import org.xtimms.shirizu.core.prefs.SourceSettings
import org.xtimms.shirizu.utils.lang.processLifecycleScope import org.xtimms.shirizu.utils.lang.processLifecycleScope
import java.util.Locale import java.util.Locale
@OptIn(InternalParsersApi::class) @OptIn(InternalParsersApi::class)
class RemoteMangaRepository( class ParserMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: ContentCache, private val cache: MemoryContentCache,
) : MangaRepository, Interceptor { ) : CachingMangaRepository(cache), Interceptor {
override val source: MangaSource override val source: MangaSource
get() = parser.source get() = parser.source
@ -51,6 +50,12 @@ class RemoteMangaRepository(
override val contentRatings: Set<ContentRating> override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating get() = parser.availableContentRating
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) {
getConfig().defaultSortOrder = value
}
override val isMultipleTagsSupported: Boolean override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported get() = parser.isMultipleTagsSupported
@ -70,7 +75,7 @@ class RemoteMangaRepository(
get() = parser.configKeyDomain.presetValues get() = parser.configKeyDomain.presetValues
val headers: Headers val headers: Headers
get() = parser.headers get() = parser.getRequestHeaders()
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) { return if (parser is Interceptor) {
@ -84,16 +89,9 @@ class RemoteMangaRepository(
return parser.getList(offset, filter) return parser.getList(offset, filter)
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) override suspend fun getPagesImpl(
chapter: MangaChapter
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { ): List<MangaPage> = parser.getPages(chapter)
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 getPageUrl(page: MangaPage): String = parser.getPageUrl(page) override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
@ -105,59 +103,19 @@ class RemoteMangaRepository(
suspend fun getFavicons(): Favicons = parser.getFavicons() suspend fun getFavicons(): Favicons = parser.getFavicons()
override suspend fun getRelated(seed: Manga): List<Manga> { override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
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()
}
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga { override suspend fun getDetailsImpl(manga: Manga): Manga = parser.getDetails(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()
}
private fun getConfig() = parser.config as SourceSettings fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues.toList()
@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> { fun isSlowdownEnabled(): Boolean {
if (isEmpty()) { return getConfig().isSlowdownEnabled
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
} }
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException private fun getConfig() = parser.config as SourceSettings
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
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 package org.xtimms.shirizu.core.parser.favicon
import android.content.Context 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.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.ImageSource import coil.decode.ImageSource
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult import coil.fetch.FetchResult
import coil.fetch.Fetcher import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
@ -15,7 +22,9 @@ import coil.network.HttpException
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import coil.size.pxOrElse import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -27,8 +36,10 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.xtimms.shirizu.core.cache.CacheDir import org.xtimms.shirizu.core.cache.CacheDir
import org.xtimms.shirizu.core.model.MangaSource 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.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.lang.writeAllCancellable
import org.xtimms.shirizu.utils.withExtraCloseable import org.xtimms.shirizu.utils.withExtraCloseable
import java.net.HttpURLConnection import java.net.HttpURLConnection
@ -46,14 +57,27 @@ class FaviconFetcher(
) : Fetcher { ) : Fetcher {
private val diskCacheKey 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 private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it } 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( val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
@ -83,6 +107,20 @@ class FaviconFetcher(
throwNSEE(lastError) 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 { private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
@ -167,12 +205,20 @@ class FaviconFetcher(
} }
} }
private fun Drawable.nonAdaptive() =
if (this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory( class Factory(
context: Context, context: Context,
private val okHttpClient: OkHttpClient, okHttpClientLazy: Lazy<OkHttpClient>,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> { ) : Fetcher.Factory<Uri> {
private val okHttpClient by okHttpClientLazy
private val diskCache = lazy { private val diskCache = lazy {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder() 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.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.model.LocalManga 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.model.isLocal
import org.xtimms.shirizu.core.parser.MangaRepository import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput
@ -49,7 +50,7 @@ class LocalMangaRepository @Inject constructor(
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) : MangaRepository { ) : MangaRepository {
override val source = MangaSource.LOCAL override val source = LocalMangaSource
private val locks = MultiMutex<Long>() private val locks = MultiMutex<Long>()
private val localMappingCache = LocalMangaMappingCache() private val localMappingCache = LocalMangaMappingCache()
@ -60,6 +61,10 @@ class LocalMangaRepository @Inject constructor(
override val states = emptySet<MangaState>() override val states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>() 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> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
@ -94,7 +99,7 @@ class LocalMangaRepository @Inject constructor(
} }
override suspend fun getDetails(manga: Manga): Manga = when { 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" "Manga is not local or saved"
} }

@ -5,6 +5,7 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter 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.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag 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 { 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( Manga(
id = json.getLong("id"), id = json.getLong("id"),
title = json.getString("title"), 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.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.xtimms.shirizu.core.model.LocalManga 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.MangaIndex
import org.xtimms.shirizu.core.parser.local.hasCbzExtension import org.xtimms.shirizu.core.parser.local.hasCbzExtension
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
@ -47,7 +48,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(), index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
) )
val manga = info?.copy2( val manga = info?.copy2(
source = MangaSource.LOCAL, source = LocalMangaSource,
url = mangaUri, url = mangaUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
@ -59,14 +60,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
// old downloads // old downloads
chapterFiles.values.elementAtOrNull(i) chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null } ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL) c.copy(url = file.toUri().toString(), source = LocalMangaSource)
}, },
) ?: Manga( ) ?: Manga(
id = root.absolutePath.longHashCode(), id = root.absolutePath.longHashCode(),
title = root.name.toHumanReadable(), title = root.name.toHumanReadable(),
url = mangaUri, url = mangaUri,
publicUrl = mangaUri, publicUrl = mangaUri,
source = MangaSource.LOCAL, source = LocalMangaSource,
coverUrl = findFirstImageEntry().orEmpty(), coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.values.mapIndexed { i, f -> chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter( MangaChapter(
@ -74,7 +75,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
number = 0f, number = 0f,
volume = 0, volume = 0,
source = MangaSource.LOCAL, source = LocalMangaSource,
uploadDate = f.creationTime, uploadDate = f.creationTime,
url = f.toUri().toString(), url = f.toUri().toString(),
scanlator = null, scanlator = null,
@ -106,7 +107,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { .map {
val pageUri = it.toUri().toString() val pageUri = it.toUri().toString()
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL) MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
} }
} else { } else {
ZipFile(file).use { zip -> ZipFile(file).use { zip ->
@ -121,7 +122,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
id = pageUri.longHashCode(), id = pageUri.longHashCode(),
url = pageUri, url = pageUri,
preview = null, 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.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.xtimms.shirizu.core.model.LocalManga 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.MangaIndex
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
import org.xtimms.shirizu.utils.AlphanumComparator import org.xtimms.shirizu.utils.AlphanumComparator
@ -47,12 +48,12 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
) )
return@use info.copy2( return@use info.copy2(
source = MangaSource.LOCAL, source = LocalMangaSource,
url = fileUri, url = fileUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
chapters = info.chapters?.map { c -> 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, title = title,
url = fileUri, url = fileUri,
publicUrl = fileUri, publicUrl = fileUri,
source = MangaSource.LOCAL, source = LocalMangaSource,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()) chapters = chapters.sortedWith(AlphanumComparator())
.mapIndexed { i, s -> .mapIndexed { i, s ->
@ -79,7 +80,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
name = s.ifEmpty { title }, name = s.ifEmpty { title },
number = 0f, number = 0f,
volume = 0, volume = 0,
source = MangaSource.LOCAL, source = LocalMangaSource,
uploadDate = 0L, uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(), url = uriBuilder.fragment(s).build().toString(),
scanlator = null, scanlator = null,
@ -135,7 +136,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
id = entryUri.longHashCode(), id = entryUri.longHashCode(),
url = entryUri, url = entryUri,
preview = null, preview = null,
source = MangaSource.LOCAL, source = LocalMangaSource,
) )
} }
} }

@ -6,13 +6,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.model.LocalMangaSource
class LocalMangaUtil( class LocalMangaUtil(
private val manga: Manga, private val manga: Manga,
) { ) {
init { init {
require(manga.source == MangaSource.LOCAL) { require(manga.source == LocalMangaSource) {
"Expected LOCAL source but ${manga.source} found" "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.ui.theme.SEED
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.network.doh.DoHProvider 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.ui.monet.PaletteStyle
import org.xtimms.shirizu.utils.lang.processLifecycleScope import org.xtimms.shirizu.utils.lang.processLifecycleScope
import org.xtimms.shirizu.utils.system.LocaleLanguageCodeMap import org.xtimms.shirizu.utils.system.LocaleLanguageCodeMap
@ -36,6 +37,8 @@ const val CONFIGURE = "configure"
const val NOTIFICATION = "notification" const val NOTIFICATION = "notification"
const val READING_TIME = "reading_time" const val READING_TIME = "reading_time"
const val GRID_COLUMNS = "grid_columns" const val GRID_COLUMNS = "grid_columns"
const val SORT_OPTION = "sort_option"
const val DOH = "doh" const val DOH = "doh"
const val SYSTEM_DEFAULT = 0 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.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> 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 } as T
} }
@ -40,6 +41,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, value as String?) is ConfigKey.UserAgent -> putString(key.key, value as String?)
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) 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.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.model.getPreferredBranch import org.xtimms.shirizu.core.model.getPreferredBranch
import org.xtimms.shirizu.core.parser.MangaRepository 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.MangaTracking
import org.xtimms.shirizu.core.tracker.model.MangaUpdates import org.xtimms.shirizu.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.data.repository.HistoryRepository import org.xtimms.shirizu.data.repository.HistoryRepository
@ -51,7 +51,7 @@ class Tracker @Inject constructor(
): MangaUpdates = withMangaLock(track.manga.id) { ): MangaUpdates = withMangaLock(track.manga.id) {
val updates = runCatchingCancellable { val updates = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(track.manga.source) 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) val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
compare(track, manga, getBranch(manga)) compare(track, manga, getBranch(manga))
}.getOrElse { error -> }.getOrElse { error ->

@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@ -123,22 +122,6 @@ class MangaSearchRepository @Inject constructor(
return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() 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) { fun saveSearchQuery(query: String) {
recentSuggestions.saveRecentQuery(query, null) recentSuggestions.saveRecentQuery(query, null)
} }

@ -1,23 +1,38 @@
package org.xtimms.shirizu.data.repository 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.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.parsers.model.ContentType 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.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet 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.ShirizuDatabase
import org.xtimms.shirizu.core.database.dao.MangaSourcesDao import org.xtimms.shirizu.core.database.dao.MangaSourcesDao
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
import org.xtimms.shirizu.core.model.MangaSource 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.model.isNsfw
import org.xtimms.shirizu.core.parser.external.ExternalMangaSource
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.KotatsuAppSettings import org.xtimms.shirizu.core.prefs.KotatsuAppSettings
import org.xtimms.shirizu.core.prefs.observeAsFlow 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 org.xtimms.shirizu.utils.ReversibleHandle
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class) @Singleton
@Reusable
class MangaSourcesRepository @Inject constructor( class MangaSourcesRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: ShirizuDatabase, private val db: ShirizuDatabase,
private val settings: KotatsuAppSettings, private val settings: KotatsuAppSettings,
) { ) {
private val isNewSourcesAssimilated = AtomicBoolean(false)
private val dao: MangaSourcesDao private val dao: MangaSourcesDao
get() = db.getSourcesDao() get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
remove(MangaSource.LOCAL) if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY) remove(MangaParserSource.DUMMY)
}
} }
val allMangaSources: Set<MangaSource> val allMangaSources: Set<MangaParserSource>
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {
@ -54,7 +73,8 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled) return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled)
} }
fun observeDisabledSources(): Flow<List<MangaSource>> = combine( @OptIn(ExperimentalCoroutinesApi::class)
fun observeDisabledSources(): Flow<List<MangaParserSource>> = combine(
observeIsNsfwDisabled(), observeIsNsfwDisabled(),
observeSortOrder(), observeSortOrder(),
) { skipNsfw, _ -> ) { skipNsfw, _ ->
@ -84,7 +104,7 @@ class MangaSourcesRepository @Inject constructor(
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeEnabledSources(): Flow<List<MangaSource>> = combine( fun observeEnabledSources(): Flow<List<MangaParserSource>> = combine(
observeIsNsfwDisabled(), observeIsNsfwDisabled(),
observeSortOrder(), observeSortOrder(),
) { skipNsfw, order -> ) { 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() val new = getNewSources()
if (new.isEmpty()) { if (new.isEmpty()) {
return emptySet() return false
} }
var maxSortKey = dao.getMaxSortKey() var maxSortKey = dao.getMaxSortKey()
val entities = new.map { x -> val entities = new.map { x ->
@ -131,20 +154,20 @@ class MangaSourcesRepository @Inject constructor(
source = x.name, source = x.name,
isEnabled = false, isEnabled = false,
sortKey = ++maxSortKey, sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
isPinned = false,
) )
} }
dao.insertIfAbsent(entities) dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) { return true
new.removeAll { x -> x.isNsfw() }
}
return new
} }
suspend fun isSetupRequired(): Boolean { suspend fun isSetupRequired(): Boolean {
return dao.findAll().isEmpty() return dao.findAll().isEmpty()
} }
private suspend fun getNewSources(): MutableSet<MangaSource> { private suspend fun getNewSources(): MutableSet<out MangaSource> {
val entities = dao.findAll() val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources) val result = EnumSet.copyOf(remoteSources)
for (e in entities) { for (e in entities) {
@ -155,11 +178,11 @@ class MangaSourcesRepository @Inject constructor(
private fun List<MangaSourceEntity>.toSources( private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean, skipNsfwSources: Boolean,
): List<MangaSource> { ): List<MangaParserSource> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaParserSource>(size)
for (entity in this) { for (entity in this) {
val source = MangaSource(entity.source) val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.contentType == ContentType.HENTAI) { if (skipNsfwSources && source.isNsfw()) {
continue continue
} }
if (source in remoteSources) { if (source in remoteSources) {
@ -169,6 +192,41 @@ class MangaSourcesRepository @Inject constructor(
return result 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 observeIsNsfwDisabled() = MutableStateFlow(AppSettings.isNSFWEnabled()).asStateFlow()
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_NEW) { 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) { private fun observeSortOrder() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_ORDER) {
sourcesSortOrder 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.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault 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.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.xtimms.shirizu.core.database.entity.BookmarkEntity import org.xtimms.shirizu.core.database.entity.BookmarkEntity
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
@ -83,6 +84,9 @@ class JsonDeserializer(private val json: JSONObject) {
source = json.getString("source"), source = json.getString("source"),
isEnabled = json.getBoolean("enabled"), isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"), 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?> { fun toMap(): Map<String, Any?> {

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

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

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

@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource 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.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.utils.lang.AssistContentScreen import org.xtimms.shirizu.utils.lang.AssistContentScreen
import org.xtimms.shirizu.utils.lang.Screen import org.xtimms.shirizu.utils.lang.Screen
@ -26,13 +27,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
class DetailsScreen( class DetailsScreen(
private val manga: Manga, private val mangaId: Long,
val fromSource: Boolean = false, val fromSource: Boolean = false,
) : Screen(), AssistContentScreen { ) : Screen() {
private var assistUrl: String? = null
override fun onProvideAssistUrl() = assistUrl
@Composable @Composable
override fun Content() { override fun Content() {
@ -43,7 +40,7 @@ class DetailsScreen(
val screenModel = val screenModel =
getScreenModel<DetailsScreenModel, DetailsScreenModel.Factory> { factory -> getScreenModel<DetailsScreenModel, DetailsScreenModel.Factory> { factory ->
factory.create(context, manga, SnackbarHostState()) factory.create(context, mangaId, SnackbarHostState())
} }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
@ -54,13 +51,13 @@ class DetailsScreen(
} }
val successState = state as DetailsScreenModel.State.Success val successState = state as DetailsScreenModel.State.Success
val isOnlineSource = remember { successState.source != MangaSource.DUMMY && successState.source != MangaSource.LOCAL }
MangaScreen( MangaScreen(
state = successState, state = successState,
snackbarHostState = screenModel.snackbarHostState, snackbarHostState = screenModel.snackbarHostState,
isTabletUi = isTabletUi(), isTabletUi = isTabletUi(),
onBackClicked = navigator::pop, onBackClicked = navigator::pop,
onMangaClicked = { },
onWebViewClicked = { onWebViewClicked = {
}, },
@ -77,15 +74,4 @@ class DetailsScreen(
onCoverClicked = { }, 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 android.content.Context
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.hilt.ScreenModelFactory import cafe.adriel.voyager.hilt.ScreenModelFactory
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async
import kotlinx.coroutines.Job import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest
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.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.parsers.model.MangaSource import org.xtimms.shirizu.core.parser.MangaDataRepository
import org.xtimms.shirizu.core.base.viewmodel.BaseStateScreenModel
import org.xtimms.shirizu.core.model.findById
import org.xtimms.shirizu.core.model.getPreferredBranch
import org.xtimms.shirizu.data.repository.BookmarksRepository import org.xtimms.shirizu.data.repository.BookmarksRepository
import org.xtimms.shirizu.data.repository.FavouritesRepository import org.xtimms.shirizu.data.repository.FavouritesRepository
import org.xtimms.shirizu.data.repository.HistoryRepository import org.xtimms.shirizu.data.repository.HistoryRepository
import org.xtimms.shirizu.sections.details.data.MangaDetails 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.DetailsInteractor
import org.xtimms.shirizu.sections.details.domain.DetailsLoadUseCase import org.xtimms.shirizu.sections.details.domain.DetailsLoadUseCase
import org.xtimms.shirizu.sections.details.domain.ReadingTimeUseCase import org.xtimms.shirizu.sections.details.domain.ReadingTimeUseCase
import org.xtimms.shirizu.sections.details.domain.RelatedMangaUseCase import org.xtimms.shirizu.sections.details.domain.RelatedMangaUseCase
import org.xtimms.shirizu.sections.details.model.ChapterItem import org.xtimms.shirizu.sections.details.model.ChapterItem
import org.xtimms.shirizu.sections.details.model.HistoryInfo import org.xtimms.shirizu.utils.system.getDisplayMessage
import org.xtimms.shirizu.sections.details.model.MangaBranch
import org.xtimms.shirizu.utils.lang.onEachWhile
class DetailsScreenModel @AssistedInject constructor( class DetailsScreenModel @AssistedInject constructor(
@Assisted val context: Context, @Assisted val context: Context,
@Assisted val manga: Manga, @Assisted val mangaId: Long,
private val interactor: DetailsInteractor, private val interactor: DetailsInteractor,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
@ -57,13 +44,16 @@ class DetailsScreenModel @AssistedInject constructor(
private val readingTimeUseCase: ReadingTimeUseCase, private val readingTimeUseCase: ReadingTimeUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase,
@Assisted val snackbarHostState: SnackbarHostState = SnackbarHostState(), @Assisted val snackbarHostState: SnackbarHostState = SnackbarHostState(),
) : BaseStateScreenModel<DetailsScreenModel.State>(State.Loading) { ) : StateScreenModel<DetailsScreenModel.State>(State.Loading) {
private val successState: State.Success? private val successState: State.Success?
get() = state.value as? State.Success get() = state.value as? State.Success
private val _events: Channel<Event> = Channel(Channel.UNLIMITED) val details: MangaDetails?
val events: Flow<Event> = _events.receiveAsFlow() get() = successState?.details
val history = historyRepository.observeOne(mangaId)
.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private inline fun updateSuccessState(func: (State.Success) -> State.Success) { private inline fun updateSuccessState(func: (State.Success) -> State.Success) {
mutableState.update { mutableState.update {
@ -74,143 +64,56 @@ class DetailsScreenModel @AssistedInject constructor(
} }
} }
private var loadingJob: Job private val selectedPositions: Array<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedChapterIds: HashSet<Long> = HashSet()
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())
val branches: StateFlow<List<MangaBranch>> = combine( init {
details, screenModelScope.launch(Dispatchers.IO) {
selectedBranch, detailsLoadUseCase.invoke(mangaId)
history, .collectLatest { details ->
) { m, b, h -> updateSuccessState {
val c = m.chapters it.copy(
if (c.isEmpty()) { details = details
return@combine emptyList() )
}
}
} }
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 { screenModelScope.launch(Dispatchers.IO) {
it.isLoaded && it.allChapters.isEmpty() val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId))
}.stateIn(screenModelScope, SharingStarted.WhileSubscribed(), false) val details = MangaDetails(manga, null, null, false)
val chapters = combine( val needRefreshInfo = !details.isLoaded
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 readingTime = combine( mutableState.update {
details, State.Success(
selectedBranch, details = details
history, )
) { m, b, h -> }
readingTimeUseCase.invoke(m, b, h)
}.stateIn(screenModelScope, SharingStarted.Lazily, null)
val selectedBranchValue: String? if (screenModelScope.isActive) {
get() = selectedBranch.value val fetchFromSourceTasks = listOf(
async { if (needRefreshInfo) fetchMangaFromSource() },
)
fetchFromSourceTasks.awaitAll()
}
init { updateSuccessState { it.copy(isRefreshingData = false) }
loadingJob = doLoad(manga.id) }
updateSuccessState { it.copy(isRefreshingData = false) }
} }
private fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) { private suspend fun fetchMangaFromSource(manualFetch: Boolean = false) {
detailsLoadUseCase.invoke(mangaId) val state = successState ?: return
.onEachWhile { try {
if (it.allChapters.isEmpty()) { withContext(Dispatchers.IO) {
return@onEachWhile false val networkManga = state.details.toManga()
} detailsLoadUseCase.getDetails(networkManga)
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
)
}
} }
} catch (e: Throwable) {
screenModelScope.launch {
snackbarHostState.showSnackbar(message = with(context) { e.getDisplayMessage(resources) })
}
}
} }
private fun List<ChapterItem>.filterSearch(query: String): List<ChapterItem> { 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 { sealed interface Event {
data object InternalError : Event data object InternalError : Event
} }
@ -238,12 +135,7 @@ class DetailsScreenModel @AssistedInject constructor(
@Immutable @Immutable
data class Success( data class Success(
val manga: Manga, val details: MangaDetails,
val source: MangaSource,
val historyInfo: HistoryInfo,
val readingTime: ReadingTime,
val availableScanlators: Set<String>,
val excludedScanlators: Set<String>,
val isRefreshingData: Boolean = false, val isRefreshingData: Boolean = false,
) : State ) : State
} }
@ -252,7 +144,7 @@ class DetailsScreenModel @AssistedInject constructor(
interface Factory : ScreenModelFactory { interface Factory : ScreenModelFactory {
fun create( fun create(
context: Context, context: Context,
manga: Manga, mangaId: Long,
snackbarHostState: SnackbarHostState snackbarHostState: SnackbarHostState
): DetailsScreenModel ): 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 package org.xtimms.shirizu.sections.details
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight 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.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -19,13 +31,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.ClassicDetailsToolbar 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.Scaffold
import org.xtimms.shirizu.core.components.VerticalFastScroller import org.xtimms.shirizu.core.components.VerticalFastScroller
import org.xtimms.shirizu.core.model.MangaHistory 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.parser.favicon.faviconUri
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.sections.details.data.ReadingTime import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo import org.xtimms.shirizu.sections.details.model.HistoryInfo
import java.time.Instant import java.time.Instant
@ -36,6 +53,7 @@ fun MangaScreen(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
isTabletUi: Boolean, isTabletUi: Boolean,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onMangaClicked: (Manga) -> Unit,
onWebViewClicked: (() -> Unit)?, onWebViewClicked: (() -> Unit)?,
onWebViewLongClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?,
onTrackingClicked: () -> Unit, onTrackingClicked: () -> Unit,
@ -56,6 +74,7 @@ fun MangaScreen(
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
onTagSearch = onTagSearch, onTagSearch = onTagSearch,
onMangaClicked = onMangaClicked,
onRefresh = onRefresh, onRefresh = onRefresh,
) )
} }
@ -68,6 +87,7 @@ private fun MangaScreenSmallImpl(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onTagSearch: (String) -> Unit, onTagSearch: (String) -> Unit,
onMangaClicked: (Manga) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
val chapterListState = rememberLazyListState() val chapterListState = rememberLazyListState()
@ -91,7 +111,7 @@ private fun MangaScreenSmallImpl(
label = "Top Bar Background", label = "Top Bar Background",
) )
ClassicDetailsToolbar( ClassicDetailsToolbar(
title = state.manga?.title ?: "", title = state.details.toManga().title,
titleAlphaProvider = { animatedTitleAlpha }, titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha }, backgroundAlphaProvider = { animatedBgAlpha },
navigateBack = { onBackClicked() }, navigateBack = { onBackClicked() },
@ -102,6 +122,8 @@ private fun MangaScreenSmallImpl(
val topPadding = contentPadding.calculateTopPadding() val topPadding = contentPadding.calculateTopPadding()
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val relatedMangaListState = rememberLazyListState()
VerticalFastScroller( VerticalFastScroller(
listState = chapterListState, listState = chapterListState,
topContentPadding = topPadding, topContentPadding = topPadding,
@ -121,16 +143,16 @@ private fun MangaScreenSmallImpl(
contentType = DetailsScreenItem.INFO_BOX, contentType = DetailsScreenItem.INFO_BOX,
) { ) {
ClassicDetailsInfoBox( ClassicDetailsInfoBox(
imageUrl = state.manga.largeCoverUrl ?: state.manga.coverUrl, imageUrl = state.details.toManga().largeCoverUrl ?: state.details.toManga().coverUrl,
favicon = state.manga.source.faviconUri(), favicon = state.details.toManga().source.faviconUri(),
title = state.manga.title, title = state.details.toManga().title,
altTitle = state.manga.altTitle ?: stringResource(id = R.string.unknown), altTitle = state.details.toManga().altTitle,
author = state.manga.author ?: stringResource(id = R.string.unknown), author = state.details.toManga().author,
isNsfw = state.manga.isNsfw, isNsfw = state.details.toManga().isNsfw,
state = state.manga.state, state = state.details.toManga().state,
source = state.manga.source, source = state.details.toManga().source,
historyInfo = state.historyInfo, historyInfo = null,
readingTime = state.readingTime, readingTime = null,
isTabletUi = false, isTabletUi = false,
appBarPadding = topPadding, appBarPadding = topPadding,
onCoverClick = { }, onCoverClick = { },
@ -147,12 +169,71 @@ private fun MangaScreenSmallImpl(
) { ) {
ExpandableMangaDescription( ExpandableMangaDescription(
defaultExpandState = false, defaultExpandState = false,
description = state.manga?.description, description = state.details.toManga().description,
tagsProvider = { state.manga?.tags }, tagsProvider = { state.details.toManga().tags },
onTagSearch = onTagSearch, onTagSearch = onTagSearch,
onCopyTagToClipboard = { }, 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.shirizu.core.ShirizuAsyncImage import org.xtimms.shirizu.core.ShirizuAsyncImage
@ -38,7 +39,7 @@ fun ModernDetailsInfoBox(
author: String, author: String,
isNsfw: Boolean, isNsfw: Boolean,
state: MangaState?, state: MangaState?,
source: MangaSource, source: MangaParserSource,
historyInfo: HistoryInfo, historyInfo: HistoryInfo,
readingTime: ReadingTime?, readingTime: ReadingTime?,
isTabletUi: Boolean, isTabletUi: Boolean,

@ -32,7 +32,7 @@ class DetailsLoadUseCase @Inject constructor(
) { ) {
operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow { 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" "Cannot resolve id $mangaId"
} }
val local = if (!manga.isLocal) { 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) val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed) repository.getDetails(seed)
}.getOrThrow() }.getOrThrow()

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

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

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

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

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

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

@ -73,12 +73,7 @@ class HistoryScreenModel @Inject constructor(
val searchQuery = query ?: "" val searchQuery = query ?: ""
history.asSequence().map { it } history.asSequence().map { it }
.filter { it.manga.isNsfw == nsfw } .filter { it.manga.isNsfw == nsfw }
.sortedByDescending { .sortedWith(MangaComparator(sort))
when (sort) {
SortOption.DATE_ADDED -> it.history.updatedAt
SortOption.ALPHABETICAL -> it.manga.title.lowercase()
}.toString()
}
.filter(queryFilter(searchQuery)).toList() .filter(queryFilter(searchQuery)).toList()
.toImmutableList() .toImmutableList()
}.collectLatest { }.collectLatest {
@ -222,10 +217,7 @@ class HistoryScreenModel @Inject constructor(
val searchQuery: String? = null, val searchQuery: String? = null,
val selection: PersistentList<Manga> = persistentListOf(), val selection: PersistentList<Manga> = persistentListOf(),
val showNsfw: Boolean = AppSettings.showNsfwInHistory(), val showNsfw: Boolean = AppSettings.showNsfwInHistory(),
val availableSorts: List<SortOption> = listOf( val availableSorts: List<SortOption> = SortOption.entries,
SortOption.DATE_ADDED,
SortOption.ALPHABETICAL
),
val sort: SortOption = SortOption.ALPHABETICAL, val sort: SortOption = SortOption.ALPHABETICAL,
val list: PersistentList<HistoryItemModel> = persistentListOf(), val list: PersistentList<HistoryItemModel> = persistentListOf(),
val dialog: Dialog? = null, 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.Scaffold
import org.xtimms.shirizu.core.components.LibraryBottomActionMenu import org.xtimms.shirizu.core.components.LibraryBottomActionMenu
import org.xtimms.shirizu.core.components.ShirizuDialog 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.core.ui.screens.TabContent
import org.xtimms.shirizu.sections.details.DetailsScreen import org.xtimms.shirizu.sections.details.DetailsScreen
@ -80,7 +81,7 @@ fun Screen.historyTab(): TabContent {
onToggleEnableNsfw = { screenModel.filterNsfw(it) }, onToggleEnableNsfw = { screenModel.filterNsfw(it) },
onFilterChanged = { screenModel.search(it) }, onFilterChanged = { screenModel.search(it) },
onSortSelected = { screenModel.sort(it) }, onSortSelected = { screenModel.sort(it) },
onClick = { navigator.push(DetailsScreen(it.manga)) }, onClick = { navigator.push(DetailsScreen(it.manga.id)) },
onHistorySelected = screenModel::toggleSelection onHistorySelected = screenModel::toggleSelection
) )
} }

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

@ -26,6 +26,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.components.Scaffold 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.sections.details.DetailsScreen
import org.xtimms.shirizu.utils.lang.Screen 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), columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
contentPadding = paddingValues, contentPadding = paddingValues,
onMangaClick = { navigator.push((DetailsScreen(it, true))) }, onMangaClick = { navigator.push((DetailsScreen(it.id, true))) },
onMangaLongClick = { manga -> }, onMangaLongClick = { manga -> },
) )
} }

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

@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.AppBar import org.xtimms.shirizu.core.components.AppBar
@ -21,7 +22,7 @@ import org.xtimms.shirizu.core.components.SearchToolbar
fun BrowseSourceToolbar( fun BrowseSourceToolbar(
searchQuery: String?, searchQuery: String?,
onSearchQueryChange: (String?) -> Unit, onSearchQueryChange: (String?) -> Unit,
source: MangaSource?, source: MangaParserSource?,
navigateUp: () -> Unit, navigateUp: () -> Unit,
onWebViewClick: () -> Unit, onWebViewClick: () -> Unit,
onSearch: (String) -> 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.MangaHttpClient
import org.xtimms.shirizu.core.network.interceptors.ImageProxyInterceptor import org.xtimms.shirizu.core.network.interceptors.ImageProxyInterceptor
import org.xtimms.shirizu.core.parser.MangaRepository 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.isFileUri
import org.xtimms.shirizu.core.parser.local.isZipUri 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.sections.reader.pager.ReaderPage
import org.xtimms.shirizu.utils.FileSize import org.xtimms.shirizu.utils.FileSize
import org.xtimms.shirizu.utils.RetainedLifecycleCoroutineScope import org.xtimms.shirizu.utils.RetainedLifecycleCoroutineScope
@ -81,7 +80,7 @@ class PageLoader @Inject constructor(
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository return repository is ParserMangaRepository
// && settings.isPagesPreloadEnabled // && settings.isPagesPreloadEnabled
&& !context.isPowerSaveMode() && !context.isPowerSaveMode()
&& !isLowRam() && !isLowRam()

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

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

@ -81,11 +81,6 @@ class GlobalSearchScreenModel @Inject constructor(
} else { } else {
null null
} }
val sources = if (SearchSuggestionType.SOURCES in types) {
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
} else {
null
}
val tags = tagsDeferred?.await() val tags = tagsDeferred?.await()
val mangaList = mangaDeferred?.await() val mangaList = mangaDeferred?.await()
@ -93,11 +88,10 @@ class GlobalSearchScreenModel @Inject constructor(
val hints = hintsDeferred?.await() val hints = hintsDeferred?.await()
val authors = authorsDeferred?.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()) { if (!mangaList.isNullOrEmpty()) {
add(SearchSuggestionItem.MangaList(mangaList)) add(SearchSuggestionItem.MangaList(mangaList))
} }
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) }
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) } queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
authors?.mapTo(this) { SearchSuggestionItem.Author(it) } authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
hints?.mapTo(this) { SearchSuggestionItem.Hint(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.ContentType
import org.koitharu.kotatsu.parsers.model.Manga 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.MangaSource
import org.xtimms.shirizu.core.model.ListModel import org.xtimms.shirizu.core.model.ListModel
@ -44,7 +45,7 @@ sealed interface SearchSuggestionItem : ListModel {
} }
data class Source( data class Source(
val source: MangaSource, val source: MangaParserSource,
val isEnabled: Boolean, val isEnabled: Boolean,
) : SearchSuggestionItem { ) : 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.ShirizuAsyncImage import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.parser.favicon.faviconUri import org.xtimms.shirizu.core.parser.favicon.faviconUri
@ -23,7 +24,7 @@ import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable @Composable
fun SourceCatalogItem( fun SourceCatalogItem(
source: MangaSource, source: MangaParserSource,
) { ) {
Row( Row(
@ -53,6 +54,6 @@ fun SourceCatalogItem(
@Composable @Composable
fun SourceCatalogItemPreview() { fun SourceCatalogItemPreview() {
ShirizuTheme { 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(
items = sources, items = sources,
) { item -> ) { item ->
item.items.forEach { source -> /*item.items.forEach { source ->
SourceCatalogItem( SourceCatalogItem(
source = source.source, 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.R
import org.xtimms.shirizu.core.components.Scaffold import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.LibraryBottomActionMenu 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.EmptyScreen
import org.xtimms.shirizu.core.ui.screens.LoadingScreen import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.sections.details.DetailsScreen import org.xtimms.shirizu.sections.details.DetailsScreen
@ -85,7 +86,7 @@ object ShelfTab : Tab, NoLiftingAppBarScreen {
currentPage = { screenModel.activeCategoryIndex }, currentPage = { screenModel.activeCategoryIndex },
hasActiveFilters = state.hasActiveFilters, hasActiveFilters = state.hasActiveFilters,
onChangeCurrentPage = { }, onChangeCurrentPage = { },
onMangaClicked = { navigator.push(DetailsScreen(it)) }, onMangaClicked = { navigator.push(DetailsScreen(it.id)) },
onToggleSelection = { }, onToggleSelection = { },
onToggleRangeSelection = { onToggleRangeSelection = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) 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.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaChapter 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.MangaSource
import org.xtimms.shirizu.ui.theme.colorMax import org.xtimms.shirizu.ui.theme.colorMax
import org.xtimms.shirizu.ui.theme.colorMin import org.xtimms.shirizu.ui.theme.colorMin
@ -158,12 +159,12 @@ private fun PreviewChart() {
ChaptersChart( ChaptersChart(
modifier = Modifier.size(100.dp), modifier = Modifier.size(100.dp),
chapters = listOf( chapters = listOf(
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, "", MangaSource.DUMMY), MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.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, "", MangaSource.DUMMY), MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.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, "", MangaSource.DUMMY) MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.DUMMY)
), ),
chartPadding = PaddingValues(vertical = 16.dp) 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaChapter 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.MangaSource
import org.xtimms.shirizu.ui.theme.colorMax import org.xtimms.shirizu.ui.theme.colorMax
import org.xtimms.shirizu.ui.theme.colorMin import org.xtimms.shirizu.ui.theme.colorMin
@ -98,12 +99,12 @@ fun MinMaxReadCard(
.fillMaxHeight() .fillMaxHeight()
.fillMaxWidth(), .fillMaxWidth(),
chapters = listOf( chapters = listOf(
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, "", MangaSource.DUMMY), MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.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, "", MangaSource.DUMMY), MangaChapter(id = 1, name = "", number = 1, "", "", 0L, "", MangaParserSource.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, "", MangaSource.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.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.combineColors
import org.xtimms.shirizu.utils.material.harmonize
import org.xtimms.shirizu.utils.material.harmonizeWithColor import org.xtimms.shirizu.utils.material.harmonizeWithColor
import org.xtimms.shirizu.utils.material.toPalette 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( var baseColors = listOf(
Color(0xFFF86BAE), Color(0xFFF86BAE),
@ -35,10 +50,13 @@ var baseColors = listOf(
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun CategoriesChart( fun CategoriesChart(
modifier: Modifier = Modifier modifier: Modifier = Modifier,
categories: List<ShelfCategory>
) { ) {
val isNightMode = isSystemInDarkTheme() val isNightMode = isSystemInDarkTheme()
val labelWithoutTag = stringResource(R.string.progress)
val maxDisplay = 7
val colors = baseColors.map { val colors = baseColors.map {
toPalette( 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( Card(
modifier = modifier, modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = combineColors( containerColor = combineColors(
@ -60,25 +130,21 @@ fun CategoriesChart(
), ),
) )
) { ) {
Box { DonutChart(
Column( modifier = Modifier
modifier = Modifier .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
.fillMaxWidth() .size(64.dp),
.padding(16.dp), items = tags,
verticalArrangement = Arrangement.Center, )
horizontalAlignment = Alignment.CenterHorizontally, FlowRow(Modifier.padding(4.dp, 4.dp)) {
) { tags.forEach { tag ->
DonutChart( TagAmount(
modifier = Modifier modifier = Modifier.padding(4.dp, 4.dp),
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) value = tag.name,
.size(64.dp), palette = tag.color,
items = emptyList(), 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.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.sections.shelf.ShelfCategory import org.xtimms.shirizu.sections.shelf.ShelfCategory
import java.math.RoundingMode
@Composable @Composable
fun DonutChart( fun DonutChart(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
items: List<ShelfCategory>, items: List<TagUsage>,
chartPadding: PaddingValues = PaddingValues(0.dp), chartPadding: PaddingValues = PaddingValues(0.dp),
) { ) {
val localDensity = LocalDensity.current val localDensity = LocalDensity.current
@ -42,7 +43,7 @@ fun DonutChart(
val heightWithPaddings = height - topOffset - bottomOffset val heightWithPaddings = height - topOffset - bottomOffset
val widthWithPaddings = width - startOffset - endOffset 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 var offset = 0f
val gap = 0f val gap = 0f
@ -52,11 +53,37 @@ fun DonutChart(
val minSweepAngle = 28f val minSweepAngle = 28f
val offsetAngle = -90f 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 -> items.forEachIndexed { index, tag ->
val sweepAngle = itemAngles[index]
drawArc( drawArc(
Color.Black, tag.color?.main ?: Color.Black,
startAngle = offset + halfGap + offsetAngle, startAngle = offset + halfGap + offsetAngle,
sweepAngle = 36 - gap, sweepAngle = sweepAngle - gap,
useCenter = false, useCenter = false,
topLeft = Offset(startOffset + halfStrokeWidth, topOffset + halfStrokeWidth), topLeft = Offset(startOffset + halfStrokeWidth, topOffset + halfStrokeWidth),
size = Size(widthWithPaddings - strokeWidth, heightWithPaddings - strokeWidth), 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)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "56", text = amount.toString(),
softWrap = false, softWrap = false,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.W900), 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.MangaGridItem
import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.components.icons.Creation 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.EmptyScreen
import org.xtimms.shirizu.core.ui.screens.LoadingScreen import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import org.xtimms.shirizu.sections.details.DetailsScreen import org.xtimms.shirizu.sections.details.DetailsScreen
@ -84,7 +85,7 @@ object SuggestionsScreen : Screen() {
SuggestionsScreenContent( SuggestionsScreenContent(
suggestions = it, suggestions = it,
contentPadding = padding, 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 androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> { class MultiMutex<T : Any> : Set<T> {
@ -40,4 +43,17 @@ class MultiMutex<T : Any> : Set<T> {
delegates.remove(element)?.unlock() 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.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import org.json.JSONObject import org.json.JSONObject
@ -72,3 +73,5 @@ private fun Char.isValidForHeaderValue(): Boolean {
// from okhttp3.Headers$Companion.checkValue // from okhttp3.Headers$Companion.checkValue
return this == '\t' || this in '\u0020'..'\u007e' 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 package org.xtimms.shirizu.utils.system
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import java.util.Locale 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.toList(): List<Locale> = List(size()) { i -> getOrThrow(i) }
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()

@ -356,9 +356,16 @@
<string name="shelves">Shelves</string> <string name="shelves">Shelves</string>
<string name="history_cleared">History cleared</string> <string name="history_cleared">History cleared</string>
<string name="search_by_reading_history">Search by reading history</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="sort_date_added">Date added</string>
<string name="show_nsfw">Show NSFW</string> <string name="show_nsfw">Show NSFW</string>
<string name="action_save">Save</string> <string name="action_save">Save</string>
<string name="action_mark_as_completed">Mark as completed</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> </resources>
Loading…
Cancel
Save