diff --git a/app/build.gradle b/app/build.gradle
index ffdde2613..bf609409d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
- versionCode = 655
- versionName = '7.4-b1'
+ versionCode = 656
+ versionName = '7.4-rc1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3d68b4970..cfdc81f88 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,10 @@
+
+
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt
index cf47f00b2..f1abcfdb2 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt
@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core
import android.app.Application
+import android.content.ContentResolver
import android.content.Context
import android.provider.SearchRecentSuggestions
import android.text.Html
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt
index 1db95d227..5e847ad11 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt
@@ -9,12 +9,14 @@ import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.splitTwoParts
import com.google.android.material.R as materialR
data object LocalMangaSource : MangaSource {
@@ -26,12 +28,15 @@ data object UnknownMangaSource : MangaSource {
}
fun MangaSource(name: String?): MangaSource {
- when (name) {
- null,
+ 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
}
@@ -61,6 +66,8 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
context.getString(R.string.source_summary_pattern, type, locale)
}
+ is ExternalMangaSource -> context.getString(R.string.external_source)
+
else -> null
}
@@ -68,6 +75,7 @@ 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)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt
new file mode 100644
index 000000000..261f78900
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt
@@ -0,0 +1,104 @@
+package org.koitharu.kotatsu.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.BuildConfig
+import org.koitharu.kotatsu.core.cache.MemoryContentCache
+import org.koitharu.kotatsu.core.cache.SafeDeferred
+import org.koitharu.kotatsu.core.util.MultiMutex
+import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
+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
+
+abstract class CachingMangaRepository(
+ private val cache: MemoryContentCache,
+) : MangaRepository {
+
+ private val detailsMutex = MultiMutex()
+ private val relatedMangaMutex = MultiMutex()
+ private val pagesMutex = MultiMutex()
+
+ final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
+
+ final override suspend fun getPages(chapter: MangaChapter): List = 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 = 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
+
+ protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List
+
+ private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred {
+ var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
+ if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
+ dispatcher = Dispatchers.Default
+ }
+ return SafeDeferred(
+ processLifecycleScope.async(dispatcher) {
+ runCatchingCancellable { block() }
+ },
+ )
+ }
+
+ private fun List.distinctById(): List {
+ if (isEmpty()) {
+ return emptyList()
+ }
+ val result = ArrayList(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
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt
index 54f52950b..ae2bef8a9 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt
@@ -1,12 +1,16 @@
package org.koitharu.kotatsu.core.parser
+import android.content.Context
import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
+import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -57,8 +61,14 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List
+ suspend fun find(manga: Manga): Manga? {
+ val list = getList(0, MangaListFilter.Search(manga.title))
+ return list.find { x -> x.id == manga.id }
+ }
+
@Singleton
class Factory @Inject constructor(
+ @ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache,
@@ -94,6 +104,16 @@ interface MangaRepository {
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
+ is ExternalMangaSource -> if (source.isAvailable(context)) {
+ ExternalMangaRepository(
+ contentResolver = context.contentResolver,
+ source = source,
+ cache = contentCache,
+ )
+ } else {
+ EmptyMangaRepository(source)
+ }
+
else -> null
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt
index ce62d517a..f2f3a7b42 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt
@@ -1,24 +1,11 @@
package org.koitharu.kotatsu.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 okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
-import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings
-import org.koitharu.kotatsu.core.util.MultiMutex
-import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
@@ -38,13 +25,9 @@ import java.util.Locale
class ParserMangaRepository(
private val parser: MangaParser,
- private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
-) : MangaRepository, Interceptor {
-
- private val detailsMutex = MultiMutex()
- private val relatedMangaMutex = MultiMutex()
- private val pagesMutex = MultiMutex()
+ cache: MemoryContentCache,
+) : CachingMangaRepository(cache), Interceptor {
override val source: MangaParserSource
get() = parser.source
@@ -99,18 +82,11 @@ class ParserMangaRepository(
}
}
- override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
-
- override suspend fun getPages(chapter: MangaChapter): List = pagesMutex.withLock(chapter.id) {
- cache.getPages(source, chapter.url)?.let { return it }
- val pages = asyncSafe {
- mirrorSwitchInterceptor.withMirrorSwitching {
- parser.getPages(chapter).distinctById()
- }
- }
- cache.putPages(source, chapter.url, pages)
- pages
- }.await()
+ override suspend fun getPagesImpl(
+ chapter: MangaChapter
+ ): List = mirrorSwitchInterceptor.withMirrorSwitching {
+ parser.getPages(chapter)
+ }
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
@@ -128,37 +104,10 @@ class ParserMangaRepository(
parser.getFavicons()
}
- override suspend fun getRelated(seed: Manga): List = relatedMangaMutex.withLock(seed.id) {
- 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)
- related
- }.await()
+ override suspend fun getRelatedMangaImpl(seed: Manga): List = parser.getRelatedManga(seed)
- 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 {
- mirrorSwitchInterceptor.withMirrorSwitching {
- parser.getDetails(manga)
- }
- }
- if (cachePolicy.writeEnabled) {
- cache.putDetails(source, manga.url, details)
- }
- details
- }.await()
-
- suspend fun peekDetails(manga: Manga): Manga? {
- return cache.getDetails(source, manga.url)
- }
-
- suspend fun find(manga: Manga): Manga? {
- val list = getList(0, MangaListFilter.Search(manga.title))
- return list.find { x -> x.id == manga.id }
+ override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
+ parser.getDetails(manga)
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
@@ -175,40 +124,8 @@ class ParserMangaRepository(
return getConfig().isSlowdownEnabled
}
- fun invalidateCache() {
- cache.clear(source)
- }
-
fun getConfig() = parser.config as SourceSettings
- private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred {
- var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
- if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
- dispatcher = Dispatchers.Default
- }
- return SafeDeferred(
- processLifecycleScope.async(dispatcher) {
- runCatchingCancellable { block() }
- },
- )
- }
-
- private fun List.distinctById(): List {
- if (isEmpty()) {
- return emptyList()
- }
- val result = ArrayList(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
- }
-
private suspend fun MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) {
return block()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
new file mode 100644
index 000000000..a047d0ebc
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
@@ -0,0 +1,264 @@
+package org.koitharu.kotatsu.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.core.cache.MemoryContentCache
+import org.koitharu.kotatsu.core.parser.CachingMangaRepository
+import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
+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 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
+ get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
+ override val states: Set
+ get() = capabilities?.availableStates.orEmpty()
+ override val contentRatings: Set
+ 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 =
+ 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(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 = 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(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 = runInterruptible(Dispatchers.Default) {
+ val uri = "content://${source.authority}/tags".toUri()
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val result = ArraySet(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 = emptySet()
+
+ override suspend fun getRelatedMangaImpl(seed: Manga): List = 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? = 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(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,
+ val availableStates: Set,
+ val availableContentRating: Set,
+ val isMultipleTagsSupported: Boolean,
+ val isTagsExclusionSupported: Boolean,
+ val isSearchSupported: Boolean,
+ val contentType: ContentType,
+ val defaultSortOrder: SortOrder,
+ val sourceLocale: Locale,
+ )
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
new file mode 100644
index 000000000..cb15c293c
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
@@ -0,0 +1,30 @@
+package org.koitharu.kotatsu.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
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt
index 8d0cb1dcb..ef155b03e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt
@@ -1,12 +1,19 @@
package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.AdaptiveIconDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.LayerDrawable
import android.net.Uri
+import android.os.Build
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
+import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
@@ -14,7 +21,9 @@ import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -24,8 +33,10 @@ import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
+import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir
@@ -53,7 +64,20 @@ class FaviconFetcher(
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
- val repo = mangaRepositoryFactory.create(mangaSource) as ParserMangaRepository
+ return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
+ is ParserMangaRepository -> fetchParserFavicon(repo)
+ is ExternalMangaRepository -> fetchPluginIcon(repo)
+ is EmptyMangaRepository -> DrawableResult(
+ drawable = ColorDrawable(Color.WHITE),
+ isSampled = false,
+ dataSource = DataSource.MEMORY,
+ )
+
+ else -> throw IllegalArgumentException("")
+ }
+ }
+
+ private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
@@ -100,6 +124,20 @@ class FaviconFetcher(
return response
}
+ 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 fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) {
return null
@@ -165,6 +203,13 @@ class FaviconFetcher(
}
}
+ private fun Drawable.nonAdaptive() =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
+ LayerDrawable(arrayOf(background, foreground))
+ } else {
+ this
+ }
+
class Factory(
context: Context,
okHttpClientLazy: Lazy,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
index 752cf1876..e2bc04cc2 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
@@ -1,10 +1,16 @@
package org.koitharu.kotatsu.explore.data
+import android.content.BroadcastReceiver
import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.core.content.ContextCompat
import androidx.room.withTransaction
-import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
@@ -17,6 +23,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
@@ -29,8 +36,9 @@ import java.util.Collections
import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
+import javax.inject.Singleton
-@Reusable
+@Singleton
class MangaSourcesRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: MangaDatabase,
@@ -154,7 +162,14 @@ class MangaSourcesRepository @Inject constructor(
dao.observeEnabled(order).map {
it.toSources(skipNsfw, order)
}
- }.flatMapLatest { it }.onStart { assimilateNewSources() }
+ }.flatMapLatest { it }
+ .onStart { assimilateNewSources() }
+ .combine(observeExternalSources()) { enabled, external ->
+ val list = ArrayList(enabled.size + external.size)
+ external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
+ list.addAll(enabled)
+ list
+ }
fun observeAll(): Flow>> = dao.observeAll().map { entities ->
val result = ArrayList>(entities.size)
@@ -292,6 +307,40 @@ class MangaSourcesRepository @Inject constructor(
}
}
+ private fun observeExternalSources(): Flow> {
+ 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 List.toSources(
skipNsfwSources: Boolean,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
index 569e9b6de..519735223 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface
import android.content.Intent
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -21,6 +23,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.LocalMangaSource
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -41,6 +44,7 @@ import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
@@ -170,6 +174,8 @@ class ExploreFragment :
menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection
menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned }
menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned }
+ menu.findItem(R.id.action_disable)?.isVisible = selectedSources.all { it.mangaSource is MangaParserSource }
+ menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource }
return super.onPrepareActionMode(controller, mode, menu)
}
@@ -190,6 +196,13 @@ class ExploreFragment :
mode.finish()
}
+ R.id.action_delete -> {
+ selectedSources.forEach {
+ (it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
+ }
+ mode.finish()
+ }
+
R.id.action_shortcut -> {
val source = selectedSources.singleOrNull() ?: return false
viewModel.requestPinShortcut(source)
@@ -238,4 +251,14 @@ class ExploreFragment :
.create()
.show()
}
+
+ private fun uninstallExternalSource(source: ExternalMangaSource) {
+ val uri = Uri.fromParts("package", source.packageName, null)
+ val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Intent.ACTION_DELETE
+ } else {
+ Intent.ACTION_UNINSTALL_PACKAGE
+ }
+ context?.startActivity(Intent(action, uri))
+ }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
index d6d1ad5d0..7fc0bfd28 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.settings
import android.content.Context
import android.content.Intent
+import android.net.Uri
import android.os.Bundle
+import android.provider.Settings
import android.view.ViewGroup.MarginLayoutParams
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
@@ -17,6 +19,8 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
+import org.koitharu.kotatsu.core.model.MangaSourceInfo
+import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
@@ -174,9 +178,14 @@ class SettingsActivity :
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_DOWNLOADS)
- fun newSourceSettingsIntent(context: Context, source: MangaSource) =
- Intent(context, SettingsActivity::class.java)
+ fun newSourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) {
+ is MangaSourceInfo -> newSourceSettingsIntent(context, source.mangaSource)
+ is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(Uri.fromParts("package", source.packageName, null))
+
+ else -> Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(EXTRA_SOURCE, source.name)
+ }
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt
index 5e87ee293..d9b592942 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
+import java.io.File
@AndroidEntryPoint
class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenceChangeListener {
@@ -37,7 +38,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- preferenceManager.sharedPreferencesName = viewModel.source.name
+ preferenceManager.sharedPreferencesName = viewModel.source.name.replace(File.separatorChar, '$')
addPreferencesFromResource(R.xml.pref_source)
addPreferencesFromRepository(viewModel.repository)
val isValidSource = viewModel.repository !is EmptyMangaRepository
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt
index f939275c7..b241030a0 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt
@@ -10,6 +10,7 @@ import okhttp3.HttpUrl
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
+import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.SourceSettings
@@ -58,7 +59,7 @@ class SourceSettingsViewModel @Inject constructor(
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (repository) {
- is ParserMangaRepository -> {
+ is CachingMangaRepository -> {
if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) {
repository.invalidateCache()
}
diff --git a/app/src/main/res/menu/mode_source.xml b/app/src/main/res/menu/mode_source.xml
index 9b6038663..1e19cc00e 100644
--- a/app/src/main/res/menu/mode_source.xml
+++ b/app/src/main/res/menu/mode_source.xml
@@ -9,6 +9,13 @@
android:title="@string/disable"
app:showAsAction="ifRoom|withText" />
+
+
- Percent left
Chapters read
Chapters left
+ External/plugin