Support for manga sources from external APKs
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 655
|
versionCode = 656
|
||||||
versionName = '7.4-b1'
|
versionName = '7.4-rc1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core
|
package org.koitharu.kotatsu.core
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import android.text.style.SuperscriptSpan
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import org.koitharu.kotatsu.R
|
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.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
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.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
data object LocalMangaSource : MangaSource {
|
data object LocalMangaSource : MangaSource {
|
||||||
@@ -26,12 +28,15 @@ data object UnknownMangaSource : MangaSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource(name: String?): MangaSource {
|
fun MangaSource(name: String?): MangaSource {
|
||||||
when (name) {
|
when (name ?: return UnknownMangaSource) {
|
||||||
null,
|
|
||||||
UnknownMangaSource.name -> return UnknownMangaSource
|
UnknownMangaSource.name -> return UnknownMangaSource
|
||||||
|
|
||||||
LocalMangaSource.name -> return LocalMangaSource
|
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 {
|
MangaParserSource.entries.forEach {
|
||||||
if (it.name == name) return it
|
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)
|
context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ExternalMangaSource -> context.getString(R.string.external_source)
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +75,7 @@ fun MangaSource.getTitle(context: Context): String = when (this) {
|
|||||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||||
is MangaParserSource -> title
|
is MangaParserSource -> title
|
||||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||||
|
is ExternalMangaSource -> resolveName(context)
|
||||||
else -> context.getString(R.string.unknown)
|
else -> context.getString(R.string.unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<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>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import androidx.collection.ArrayMap
|
import androidx.collection.ArrayMap
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
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.local.data.LocalMangaRepository
|
||||||
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
|
||||||
@@ -57,8 +61,14 @@ 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: MemoryContentCache,
|
private val contentCache: MemoryContentCache,
|
||||||
@@ -94,6 +104,16 @@ interface MangaRepository {
|
|||||||
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is ExternalMangaSource -> if (source.isAvailable(context)) {
|
||||||
|
ExternalMangaRepository(
|
||||||
|
contentResolver = context.contentResolver,
|
||||||
|
source = source,
|
||||||
|
cache = contentCache,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EmptyMangaRepository(source)
|
||||||
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
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.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
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.network.MirrorSwitchInterceptor
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
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.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
@@ -38,13 +25,9 @@ import java.util.Locale
|
|||||||
|
|
||||||
class ParserMangaRepository(
|
class ParserMangaRepository(
|
||||||
private val parser: MangaParser,
|
private val parser: MangaParser,
|
||||||
private val cache: MemoryContentCache,
|
|
||||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||||
) : MangaRepository, Interceptor {
|
cache: MemoryContentCache,
|
||||||
|
) : CachingMangaRepository(cache), Interceptor {
|
||||||
private val detailsMutex = MultiMutex<Long>()
|
|
||||||
private val relatedMangaMutex = MultiMutex<Long>()
|
|
||||||
private val pagesMutex = MultiMutex<Long>()
|
|
||||||
|
|
||||||
override val source: MangaParserSource
|
override val source: MangaParserSource
|
||||||
get() = parser.source
|
get() = parser.source
|
||||||
@@ -99,18 +82,11 @@ class ParserMangaRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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> = pagesMutex.withLock(chapter.id) {
|
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
cache.getPages(source, chapter.url)?.let { return it }
|
parser.getPages(chapter)
|
||||||
val pages = asyncSafe {
|
}
|
||||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
|
||||||
parser.getPages(chapter).distinctById()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cache.putPages(source, chapter.url, pages)
|
|
||||||
pages
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
parser.getPageUrl(page)
|
parser.getPageUrl(page)
|
||||||
@@ -128,37 +104,10 @@ class ParserMangaRepository(
|
|||||||
parser.getFavicons()
|
parser.getFavicons()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
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)
|
|
||||||
related
|
|
||||||
}.await()
|
|
||||||
|
|
||||||
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
|
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
if (cachePolicy.readEnabled) {
|
parser.getDetails(manga)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
||||||
@@ -175,40 +124,8 @@ class ParserMangaRepository(
|
|||||||
return getConfig().isSlowdownEnabled
|
return getConfig().isSlowdownEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateCache() {
|
|
||||||
cache.clear(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConfig() = parser.config as SourceSettings
|
fun getConfig() = parser.config as SourceSettings
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
|
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return block()
|
return block()
|
||||||
|
|||||||
264
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
264
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt
vendored
Normal file
@@ -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<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,
|
||||||
|
)
|
||||||
|
}
|
||||||
30
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
30
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaSource.kt
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.core.parser.favicon
|
package org.koitharu.kotatsu.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.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
|
||||||
@@ -14,7 +21,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
|
||||||
@@ -24,8 +33,10 @@ import okio.Closeable
|
|||||||
import okio.buffer
|
import okio.buffer
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
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.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
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.requireBody
|
||||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
@@ -53,7 +64,20 @@ class FaviconFetcher(
|
|||||||
|
|
||||||
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 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(
|
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 },
|
||||||
@@ -100,6 +124,20 @@ class FaviconFetcher(
|
|||||||
return response
|
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? {
|
private fun getCached(options: Options): SourceResult? {
|
||||||
if (!options.diskCachePolicy.readEnabled) {
|
if (!options.diskCachePolicy.readEnabled) {
|
||||||
return null
|
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(
|
class Factory(
|
||||||
context: Context,
|
context: Context,
|
||||||
okHttpClientLazy: Lazy<OkHttpClient>,
|
okHttpClientLazy: Lazy<OkHttpClient>,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package org.koitharu.kotatsu.explore.data
|
package org.koitharu.kotatsu.explore.data
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import dagger.Reusable
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.Flow
|
||||||
|
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
|
||||||
@@ -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.MangaSourceInfo
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
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.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||||
@@ -29,8 +36,9 @@ import java.util.Collections
|
|||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Reusable
|
@Singleton
|
||||||
class MangaSourcesRepository @Inject constructor(
|
class MangaSourcesRepository @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val db: MangaDatabase,
|
private val db: MangaDatabase,
|
||||||
@@ -154,7 +162,14 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
dao.observeEnabled(order).map {
|
dao.observeEnabled(order).map {
|
||||||
it.toSources(skipNsfw, order)
|
it.toSources(skipNsfw, order)
|
||||||
}
|
}
|
||||||
}.flatMapLatest { it }.onStart { assimilateNewSources() }
|
}.flatMapLatest { it }
|
||||||
|
.onStart { assimilateNewSources() }
|
||||||
|
.combine(observeExternalSources()) { enabled, external ->
|
||||||
|
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
|
||||||
|
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
|
||||||
|
list.addAll(enabled)
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
||||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||||
@@ -292,6 +307,40 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 List<MangaSourceEntity>.toSources(
|
private fun List<MangaSourceEntity>.toSources(
|
||||||
skipNsfwSources: Boolean,
|
skipNsfwSources: Boolean,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.explore.ui
|
|||||||
|
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
@@ -21,6 +23,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
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.BaseFragment
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
|
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
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.adapter.TypedListSpacingDecoration
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
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.search.ui.MangaListActivity
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
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_shortcut).isVisible = isSingleSelection
|
||||||
menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned }
|
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_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)
|
return super.onPrepareActionMode(controller, mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +196,13 @@ class ExploreFragment :
|
|||||||
mode.finish()
|
mode.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_delete -> {
|
||||||
|
selectedSources.forEach {
|
||||||
|
(it.mangaSource as? ExternalMangaSource)?.let { uninstallExternalSource(it) }
|
||||||
|
}
|
||||||
|
mode.finish()
|
||||||
|
}
|
||||||
|
|
||||||
R.id.action_shortcut -> {
|
R.id.action_shortcut -> {
|
||||||
val source = selectedSources.singleOrNull() ?: return false
|
val source = selectedSources.singleOrNull() ?: return false
|
||||||
viewModel.requestPinShortcut(source)
|
viewModel.requestPinShortcut(source)
|
||||||
@@ -238,4 +251,14 @@ class ExploreFragment :
|
|||||||
.create()
|
.create()
|
||||||
.show()
|
.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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.settings
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import android.view.ViewGroup.MarginLayoutParams
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
@@ -17,6 +19,8 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
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.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
|
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
|
||||||
@@ -174,9 +178,14 @@ class SettingsActivity :
|
|||||||
Intent(context, SettingsActivity::class.java)
|
Intent(context, SettingsActivity::class.java)
|
||||||
.setAction(ACTION_MANAGE_DOWNLOADS)
|
.setAction(ACTION_MANAGE_DOWNLOADS)
|
||||||
|
|
||||||
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
|
fun newSourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) {
|
||||||
Intent(context, SettingsActivity::class.java)
|
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)
|
.setAction(ACTION_SOURCE)
|
||||||
.putExtra(EXTRA_SOURCE, source.name)
|
.putExtra(EXTRA_SOURCE, source.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenceChangeListener {
|
class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenceChangeListener {
|
||||||
@@ -37,7 +38,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
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)
|
addPreferencesFromResource(R.xml.pref_source)
|
||||||
addPreferencesFromRepository(viewModel.repository)
|
addPreferencesFromRepository(viewModel.repository)
|
||||||
val isValidSource = viewModel.repository !is EmptyMangaRepository
|
val isValidSource = viewModel.repository !is EmptyMangaRepository
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import okhttp3.HttpUrl
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
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.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
@@ -58,7 +59,7 @@ class SourceSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
when (repository) {
|
when (repository) {
|
||||||
is ParserMangaRepository -> {
|
is CachingMangaRepository -> {
|
||||||
if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) {
|
if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) {
|
||||||
repository.invalidateCache()
|
repository.invalidateCache()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
android:title="@string/disable"
|
android:title="@string/disable"
|
||||||
app:showAsAction="ifRoom|withText" />
|
app:showAsAction="ifRoom|withText" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_delete"
|
||||||
|
android:icon="@drawable/ic_delete"
|
||||||
|
android:title="@string/delete"
|
||||||
|
android:visible="false"
|
||||||
|
app:showAsAction="ifRoom|withText" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_pin"
|
android:id="@+id/action_pin"
|
||||||
android:icon="@drawable/ic_pin"
|
android:icon="@drawable/ic_pin"
|
||||||
|
|||||||
@@ -668,4 +668,5 @@
|
|||||||
<string name="percent_left">Percent left</string>
|
<string name="percent_left">Percent left</string>
|
||||||
<string name="chapters_read">Chapters read</string>
|
<string name="chapters_read">Chapters read</string>
|
||||||
<string name="chapters_left">Chapters left</string>
|
<string name="chapters_left">Chapters left</string>
|
||||||
|
<string name="external_source">External/plugin</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user