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