Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2235d0ca | ||
|
|
cbf707b403 | ||
|
|
8971c7a6a2 | ||
|
|
1576c9cdde | ||
|
|
beba4f029a | ||
|
|
7cf7a62881 | ||
|
|
c1e84715fb | ||
|
|
a3cc5726ee | ||
|
|
3023c02f12 | ||
|
|
efff034dc6 | ||
|
|
2bb5673446 | ||
|
|
0983885fa2 | ||
|
|
4449996a91 | ||
|
|
9cf496b7c4 | ||
|
|
4fb1db47ab | ||
|
|
14b89fbee2 | ||
|
|
8291c55fc9 | ||
|
|
46ddcb7518 | ||
|
|
cf2d1aa6fb | ||
|
|
ab3dd8aacb | ||
|
|
ae868fa9d1 | ||
|
|
4ecbf5978e | ||
|
|
31586cf48f | ||
|
|
3725a6e58f | ||
|
|
313c2ab2bf | ||
|
|
fe5d37f45e | ||
|
|
92f6221ba0 | ||
|
|
0590a0c56f | ||
|
|
13ffc3a515 | ||
|
|
74b36226f2 | ||
|
|
d501d0304a | ||
|
|
1059933c87 | ||
|
|
5fa58b931e | ||
|
|
ddecc72de7 | ||
|
|
d35a0c5e1e | ||
|
|
340994ce77 | ||
|
|
42b2f21c4d | ||
|
|
e4b9da54dd | ||
|
|
ccc41314ae | ||
|
|
93eb6a19a5 | ||
|
|
e4f2e19d2c | ||
|
|
73a687c9a7 | ||
|
|
32ca3c11fa | ||
|
|
0d648dd188 | ||
|
|
86b7989c89 | ||
|
|
01be6ab596 | ||
|
|
a3d01e8d34 | ||
|
|
808bd47b64 | ||
|
|
f4b506b26b | ||
|
|
1f0d2e2039 |
@@ -19,15 +19,16 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1005
|
||||
versionName = '8.1'
|
||||
versionCode = 1013
|
||||
versionName = '8.1.7'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
arg('room.generateKotlin', 'true')
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig true
|
||||
// https://issuetracker.google.com/issues/408030127
|
||||
generateLocaleConfig false
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
@@ -75,6 +76,8 @@ android {
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
'-Xjspecify-annotations=strict',
|
||||
'-Xtype-enhancement-improvements-strict-mode',
|
||||
]
|
||||
}
|
||||
room {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
|
||||
@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AutoFixService : CoroutineIntentService() {
|
||||
@@ -95,7 +95,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
|
||||
@@ -17,9 +17,9 @@ abstract class BookmarksDao {
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||
|
||||
@@ -6,10 +6,18 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -18,6 +26,9 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
|
||||
@Inject
|
||||
lateinit var proxyProvider: ProxyProvider
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -28,10 +39,21 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
onCreate2(savedInstanceState)
|
||||
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
|
||||
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
|
||||
onCreate2(savedInstanceState, mangaSource, repository)
|
||||
}
|
||||
|
||||
protected abstract fun onCreate2(savedInstanceState: Bundle?)
|
||||
protected abstract fun onCreate2(
|
||||
savedInstanceState: Bundle?,
|
||||
source: MangaSource,
|
||||
repository: ParserMangaRepository?
|
||||
)
|
||||
|
||||
override fun onApplyWindowInsets(
|
||||
v: View,
|
||||
|
||||
@@ -8,30 +8,19 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BrowserActivity : BaseBrowserActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
override fun onCreate2(savedInstanceState: Bundle?) {
|
||||
setDisplayHomeAsUp(true, true)
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
|
||||
@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.browser
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
|
||||
open class BrowserClient(
|
||||
private val proxyProvider: ProxyProvider,
|
||||
private val callback: BrowserCallback
|
||||
) : WebViewClientCompat() {
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -37,15 +39,14 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
|
||||
private lateinit var cfClient: CloudFlareClient
|
||||
|
||||
override fun onCreate2(savedInstanceState: Bundle?) {
|
||||
setDisplayHomeAsUp(true, true)
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
cfClient = CloudFlareClient(proxyProvider, cookieJar, this, url)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
|
||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
@@ -106,8 +107,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
setTitle(title)
|
||||
supportActionBar?.subtitle =
|
||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
|
||||
}
|
||||
|
||||
private fun restartCheck() {
|
||||
|
||||
@@ -4,17 +4,15 @@ import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
|
||||
private const val LOOP_COUNTER = 3
|
||||
|
||||
class CloudFlareClient(
|
||||
proxyProvider: ProxyProvider,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val callback: CloudFlareCallback,
|
||||
private val targetUrl: String,
|
||||
) : BrowserClient(proxyProvider, callback) {
|
||||
) : BrowserClient(callback) {
|
||||
|
||||
private val oldClearance = getClearance()
|
||||
private var counter = 0
|
||||
|
||||
@@ -28,7 +28,7 @@ class BackupRepository @Inject constructor(
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
||||
while (true) {
|
||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
||||
val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE)
|
||||
if (history.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class BackupRepository @Inject constructor(
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
||||
while (true) {
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE)
|
||||
if (favourites.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -78,19 +78,26 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun dumpBookmarks(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
||||
val all = db.getBookmarksDao().findAll()
|
||||
for ((m, b) in all) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
json.put("manga", manga)
|
||||
val tags = JSONArray()
|
||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
json.put("tags", tags)
|
||||
val bookmarks = JSONArray()
|
||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||
json.put("bookmarks", bookmarks)
|
||||
entry.data.put(json)
|
||||
while (true) {
|
||||
val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE)
|
||||
if (bookmarks.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += bookmarks.size
|
||||
for ((m, b) in bookmarks) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
json.put("manga", manga)
|
||||
val tags = JSONArray()
|
||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
json.put("tags", tags)
|
||||
val bookmarks = JSONArray()
|
||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||
json.put("bookmarks", bookmarks)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class NonFileUriException(
|
||||
val uri: Uri,
|
||||
) : IllegalArgumentException("Cannot resolve file name of \"$uri\"")
|
||||
@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
@@ -163,7 +164,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.maxBitmapSize
|
||||
import coil3.util.component1
|
||||
import coil3.util.component2
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
|
||||
|
||||
class AvifImageDecoder(
|
||||
private val source: ImageSource,
|
||||
@@ -20,27 +25,52 @@ class AvifImageDecoder(
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val bytes = source.source().use {
|
||||
it.inputStream().toByteBuffer()
|
||||
}
|
||||
val info = Info()
|
||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||
throw ImageDecodeException(
|
||||
null,
|
||||
"avif",
|
||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, "avif")
|
||||
}
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
val bytes = source.source().readByteBuffer()
|
||||
val decoder = AvifDecoder.create(bytes) ?: throw ImageDecodeException(
|
||||
uri = source.fileOrNull()?.toString(),
|
||||
format = "avif",
|
||||
message = "Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
try {
|
||||
val config = if (decoder.depth == 8 || decoder.alphaPresent) {
|
||||
Bitmap.Config.ARGB_8888
|
||||
} else {
|
||||
Bitmap.Config.RGB_565
|
||||
}
|
||||
val bitmap = createBitmap(decoder.width, decoder.height, config)
|
||||
val result = decoder.nextFrame(bitmap)
|
||||
if (result != 0) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(
|
||||
uri = source.fileOrNull()?.toString(),
|
||||
format = "avif",
|
||||
message = AvifDecoder.resultToString(result),
|
||||
)
|
||||
}
|
||||
// downscaling
|
||||
val (dstWidth, dstHeight) = DecodeUtils.computeDstSize(
|
||||
srcWidth = bitmap.width,
|
||||
srcHeight = bitmap.height,
|
||||
targetSize = options.size,
|
||||
scale = options.scale,
|
||||
maxSize = options.maxBitmapSize,
|
||||
)
|
||||
if (dstWidth < bitmap.width || dstHeight < bitmap.height) {
|
||||
val scaled = bitmap.scale(dstWidth, dstHeight)
|
||||
bitmap.recycle()
|
||||
DecodeResult(
|
||||
image = scaled.asImage(),
|
||||
isSampled = true,
|
||||
)
|
||||
} else {
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
decoder.release()
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.ext.MimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -24,7 +31,7 @@ object BitmapDecoderCompat {
|
||||
|
||||
@Blocking
|
||||
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
|
||||
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||
FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) }
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||
} else {
|
||||
@@ -51,6 +58,19 @@ object BitmapDecoderCompat {
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun createRegionDecoder(inoutStream: InputStream): BitmapRegionDecoder? = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(inoutStream)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(inoutStream, false)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun probeMimeType(file: File): MimeType? {
|
||||
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
|
||||
@@ -62,7 +82,7 @@ object BitmapDecoderCompat {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)?.recycle()
|
||||
return options.outMimeType?.toMimeTypeOrNull()
|
||||
options.outMimeType?.toMimeTypeOrNull()
|
||||
}.getOrNull()
|
||||
|
||||
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
||||
@@ -78,7 +98,7 @@ object BitmapDecoderCompat {
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||
val bitmap = createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, FORMAT_AVIF)
|
||||
|
||||
@@ -25,7 +25,7 @@ class CbzFetcher(
|
||||
val entryName = requireNotNull(uri.fragment)
|
||||
val fs = options.fileSystem.openZip(filePath)
|
||||
SourceFetchResult(
|
||||
source = ImageSource(entryName.toPath(), fs, closeable = fs),
|
||||
source = ImageSource(entryName.toPath(), fs),
|
||||
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import coil3.Extras
|
||||
@@ -11,7 +10,6 @@ import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.getExtra
|
||||
import coil3.request.Options
|
||||
@@ -25,24 +23,37 @@ import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.isOriginal
|
||||
import coil3.size.pxOrElse
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.util.ext.copyWithNewSource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class RegionBitmapDecoder(
|
||||
private val source: ImageSource,
|
||||
private val fetchResult: SourceFetchResult,
|
||||
private val options: Options,
|
||||
private val imageLoader: ImageLoader,
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
|
||||
override suspend fun decode(): DecodeResult? {
|
||||
val regionDecoder = BitmapDecoderCompat.createRegionDecoder(fetchResult.source.source().inputStream())
|
||||
if (regionDecoder == null) {
|
||||
val revivedFetchResult = fetchResult.copyWithNewSource()
|
||||
return try {
|
||||
val fallbackDecoder = imageLoader.components.newDecoder(
|
||||
result = revivedFetchResult,
|
||||
options = options,
|
||||
imageLoader = imageLoader,
|
||||
startIndex = 0,
|
||||
)?.first
|
||||
if (fallbackDecoder == null || fallbackDecoder is RegionBitmapDecoder) {
|
||||
null
|
||||
} else {
|
||||
fallbackDecoder.decode()
|
||||
}
|
||||
} finally {
|
||||
revivedFetchResult.source.close()
|
||||
}
|
||||
}
|
||||
checkNotNull(regionDecoder)
|
||||
val bitmapOptions = BitmapFactory.Options()
|
||||
try {
|
||||
return try {
|
||||
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
|
||||
bitmapOptions.configureConfig()
|
||||
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
|
||||
@@ -149,7 +160,7 @@ class RegionBitmapDecoder(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Decoder = RegionBitmapDecoder(result.source, options)
|
||||
): Decoder = RegionBitmapDecoder(result, options, imageLoader)
|
||||
|
||||
override fun equals(other: Any?) = other is Factory
|
||||
|
||||
|
||||
@@ -149,6 +149,8 @@ fun Manga.chaptersCount(): Int {
|
||||
return max
|
||||
}
|
||||
|
||||
fun Manga.isNsfw(): Boolean = contentRating == ContentRating.ADULT || source.isNsfw()
|
||||
|
||||
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append(query)
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
@@ -103,7 +103,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(isEnabled)
|
||||
if (showUpAsClose) {
|
||||
setHomeAsUpIndicator(materialR.drawable.ic_clear_black_24)
|
||||
setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
import org.koitharu.kotatsu.core.util.ext.requireSerializable
|
||||
@@ -43,7 +44,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>(), Vie
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.buttonBrowser.setOnClickListener(this)
|
||||
binding.textViewSummary.text = exception.message
|
||||
val isUrlAvailable = !exception.getCauseUrl().isNullOrEmpty()
|
||||
val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true
|
||||
binding.buttonBrowser.isVisible = isUrlAvailable
|
||||
binding.textViewBrowser.isVisible = isUrlAvailable
|
||||
binding.textViewDescription.setTextAndVisible(
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.view.animation.DecelerateInterpolator
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
context: Context? = null,
|
||||
attrs: AttributeSet? = null,
|
||||
) : CoordinatorLayout.Behavior<BottomNavigationView>(context, attrs) {
|
||||
) : CoordinatorLayout.Behavior<NavigationBarView>(context, attrs) {
|
||||
|
||||
@ViewCompat.NestedScrollType
|
||||
private var lastStartedType: Int = 0
|
||||
@@ -34,13 +34,13 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
|
||||
override fun layoutDependsOn(parent: CoordinatorLayout, child: NavigationBarView, dependency: View): Boolean {
|
||||
return dependency is AppBarLayout
|
||||
}
|
||||
|
||||
override fun onDependentViewChanged(
|
||||
parent: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
dependency: View,
|
||||
): Boolean {
|
||||
val appBarSize = dependency.measureHeight()
|
||||
@@ -54,7 +54,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
|
||||
override fun onStartNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
directTargetChild: View,
|
||||
target: View,
|
||||
axes: Int,
|
||||
@@ -70,7 +70,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
|
||||
override fun onNestedPreScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
target: View,
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
@@ -85,7 +85,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
|
||||
override fun onStopNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
target: View,
|
||||
type: Int,
|
||||
) {
|
||||
@@ -94,7 +94,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) {
|
||||
private fun animateBottomNavigationVisibility(child: NavigationBarView, isVisible: Boolean) {
|
||||
offsetAnimator?.cancel()
|
||||
offsetAnimator = ValueAnimator().apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
|
||||
@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.core.ui.widgets
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.TimeInterpolator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewPropertyAnimator
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
@@ -15,9 +17,11 @@ import androidx.core.view.isVisible
|
||||
import androidx.customview.view.AbsSavedState
|
||||
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationMenuView
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale
|
||||
import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
import kotlin.math.max
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val STATE_DOWN = 1
|
||||
@@ -26,12 +30,14 @@ private const val STATE_UP = 2
|
||||
private const val SLIDE_UP_ANIMATION_DURATION = 225L
|
||||
private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
|
||||
|
||||
private const val MAX_ITEM_COUNT = 6
|
||||
|
||||
class SlidingBottomNavigationView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
|
||||
@StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
|
||||
) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes),
|
||||
) : NavigationBarView(context, attrs, defStyleAttr, defStyleRes),
|
||||
CoordinatorLayout.AttachedBehavior {
|
||||
|
||||
private var currentAnimator: ViewPropertyAnimator? = null
|
||||
@@ -55,6 +61,49 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
|
||||
return behavior
|
||||
}
|
||||
|
||||
/** From BottomNavigationView **/
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
super.onTouchEvent(event)
|
||||
// Consume all events to avoid views under the BottomNavigationView from receiving touch events.
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val minHeightSpec = makeMinHeightSpec(heightMeasureSpec)
|
||||
super.onMeasure(widthMeasureSpec, minHeightSpec)
|
||||
if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
|
||||
setMeasuredDimension(
|
||||
measuredWidth,
|
||||
max(
|
||||
measuredHeight,
|
||||
suggestedMinimumHeight + paddingTop + paddingBottom,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeMinHeightSpec(measureSpec: Int): Int {
|
||||
var minHeight = suggestedMinimumHeight
|
||||
if (MeasureSpec.getMode(measureSpec) != MeasureSpec.EXACTLY && minHeight > 0) {
|
||||
minHeight += paddingTop + paddingBottom
|
||||
|
||||
return MeasureSpec.makeMeasureSpec(
|
||||
max(MeasureSpec.getSize(measureSpec), minHeight), MeasureSpec.AT_MOST,
|
||||
)
|
||||
}
|
||||
|
||||
return measureSpec
|
||||
}
|
||||
|
||||
override fun getMaxItemCount(): Int = MAX_ITEM_COUNT
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun createNavigationBarMenuView(context: Context) = BottomNavigationMenuView(context)
|
||||
|
||||
/** End **/
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val superState = super.onSaveInstanceState()
|
||||
return SavedState(superState, currentState, translationY)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
|
||||
/**
|
||||
* ProgressIndicator become INVISIBLE instead of GONE by hide() call.
|
||||
* It`s final so we need this workaround
|
||||
*/
|
||||
class GoneOnInvisibleListener(
|
||||
private val view: View,
|
||||
) : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
if (view.visibility == View.INVISIBLE) {
|
||||
view.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun attach() {
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(this)
|
||||
onGlobalLayout()
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
@@ -53,6 +52,7 @@ import okio.use
|
||||
import org.json.JSONException
|
||||
import org.jsoup.internal.StringUtil.StringJoiner
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
@@ -140,7 +140,6 @@ val Context.ramAvailable: Long
|
||||
return result.availMem
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
LocaleConfig(this).supportedLocales?.let {
|
||||
@@ -149,8 +148,7 @@ fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
}
|
||||
val tagsList = StringJoiner(",")
|
||||
try {
|
||||
val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName)
|
||||
val xpp: XmlPullParser = resources.getXml(resId)
|
||||
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
|
||||
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (xpp.eventType == XmlPullParser.START_TAG) {
|
||||
if (xpp.name == "locale") {
|
||||
|
||||
@@ -6,10 +6,13 @@ import android.widget.ImageView
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.annotation.CheckResult
|
||||
import coil3.Extras
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
@@ -28,6 +31,7 @@ import coil3.toBitmap
|
||||
import coil3.util.CoilUtils
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import org.koitharu.kotatsu.R
|
||||
import okio.buffer
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
|
||||
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
|
||||
@@ -163,3 +167,14 @@ private class CompositeImageRequestListener(
|
||||
val mangaKey = Extras.Key<Manga?>(null)
|
||||
val bookmarkKey = Extras.Key<Bookmark?>(null)
|
||||
val mangaSourceKey = Extras.Key<MangaSource?>(null)
|
||||
|
||||
@CheckResult
|
||||
fun SourceFetchResult.copyWithNewSource(): SourceFetchResult = SourceFetchResult(
|
||||
source = ImageSource(
|
||||
source = source.fileSystem.source(source.file()).buffer(),
|
||||
fileSystem = source.fileSystem,
|
||||
metadata = source.metadata,
|
||||
),
|
||||
mimeType = mimeType,
|
||||
dataSource = dataSource,
|
||||
)
|
||||
|
||||
@@ -61,6 +61,8 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
|
||||
return map { list -> list.map(transform) }
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.throttle(timeoutMillis: Long): Flow<T> = throttle { timeoutMillis }
|
||||
|
||||
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
|
||||
var lastEmittedAt = 0L
|
||||
return transformLatest { value ->
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.ResponseBody
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
@@ -30,6 +31,14 @@ suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispa
|
||||
writeAll(source.cancellable())
|
||||
}
|
||||
|
||||
fun BufferedSource.readByteBuffer(): ByteBuffer {
|
||||
val bytes = readByteArray()
|
||||
return ByteBuffer.allocateDirect(bytes.size)
|
||||
.put(bytes)
|
||||
.rewind() as ByteBuffer
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
fun InputStream.toByteBuffer(): ByteBuffer {
|
||||
val outStream = ByteArrayOutputStream(available())
|
||||
copyTo(outStream)
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
@@ -91,6 +92,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.appcompat.widget.ActionMenuView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.descendants
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -155,9 +154,9 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
|
||||
|
||||
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
|
||||
if (value) {
|
||||
if (!isVisible) show()
|
||||
show()
|
||||
} else {
|
||||
if (isVisible) hide()
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class DetailsErrorObserver(
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
snackbar.setAnchorView(activity.viewBinding.containerBottomSheet)
|
||||
if (value is NotFoundException || value is UnsupportedSourceException) {
|
||||
snackbar.duration = Snackbar.LENGTH_INDEFINITE
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
|
||||
class DetailsMenuProvider(
|
||||
private val activity: FragmentActivity,
|
||||
@@ -36,7 +37,7 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga)
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true
|
||||
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
|
||||
@@ -125,15 +125,16 @@ class ReadButtonDelegate(
|
||||
}
|
||||
|
||||
private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) {
|
||||
val isChaptersLoading = isLoading && (info.totalChapters <= 0 || info.isChapterMissing)
|
||||
buttonRead.setText(
|
||||
when {
|
||||
isLoading -> R.string.loading_
|
||||
isChaptersLoading -> R.string.loading_
|
||||
info.isIncognitoMode -> R.string.incognito
|
||||
info.canContinue -> R.string._continue
|
||||
else -> R.string.read
|
||||
},
|
||||
)
|
||||
splitButton.isEnabled = !isLoading && info.isValid
|
||||
splitButton.isEnabled = !isChaptersLoading && info.isValid
|
||||
}
|
||||
|
||||
private fun Menu.populateBranchList() {
|
||||
|
||||
@@ -100,7 +100,11 @@ abstract class ChaptersPagesViewModel(
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
|
||||
val bookmarks = mangaDetails.flatMapLatest {
|
||||
if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList())
|
||||
if (it != null) {
|
||||
bookmarksRepository.observeBookmarks(it.toManga()).withErrorHandling()
|
||||
} else {
|
||||
flowOf(emptyList())
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val chapters = combine(
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
@@ -36,7 +37,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.UUID
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
private const val CHANNEL_ID_DEFAULT = "download"
|
||||
private const val CHANNEL_ID_SILENT = "download_bg"
|
||||
@@ -70,7 +71,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
|
||||
private val actionCancel by lazy {
|
||||
NotificationCompat.Action(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
context.getString(android.R.string.cancel),
|
||||
workManager.createCancelPendingIntent(uuid),
|
||||
)
|
||||
@@ -140,10 +141,10 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
builder.setSubText(null)
|
||||
builder.setShowWhen(false)
|
||||
builder.setVisibility(
|
||||
if (state != null && state.manga.isNsfw) {
|
||||
NotificationCompat.VISIBILITY_PRIVATE
|
||||
if (state != null && state.manga.isNsfw()) {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
} else {
|
||||
NotificationCompat.VISIBILITY_PUBLIC
|
||||
NotificationCompat.VISIBILITY_PRIVATE
|
||||
},
|
||||
)
|
||||
when {
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
@@ -92,11 +93,11 @@ class LocalStorageManager @Inject constructor(
|
||||
getAvailableStorageDirs()
|
||||
}
|
||||
|
||||
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
|
||||
suspend fun resolveUri(uri: Uri): File = runInterruptible(Dispatchers.IO) {
|
||||
if (uri.isFileUri()) {
|
||||
uri.toFile()
|
||||
} else {
|
||||
uri.resolveFile(context)
|
||||
uri.resolveFile(context) ?: throw NonFileUriException(uri)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.children
|
||||
@@ -27,6 +28,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withResumed
|
||||
import androidx.transition.TransitionManager
|
||||
@@ -66,13 +68,14 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
private const val TAG_SEARCH = "search"
|
||||
|
||||
@@ -159,6 +162,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
}
|
||||
}
|
||||
|
||||
override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner, state: Lifecycle.State) {
|
||||
if (provider !is MangaSearchMenuProvider) { // do not duplicate search menu item
|
||||
super.addMenuProvider(provider, owner, state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_main, menu)
|
||||
@@ -231,6 +240,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
topMargin = barsInsets.top
|
||||
bottomMargin = barsInsets.bottom
|
||||
}
|
||||
updateContainerBottomMargin()
|
||||
return insets.consume(v, typeMask, start = viewBinding.navRail != null)
|
||||
}
|
||||
|
||||
@@ -429,9 +439,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
supportActionBar?.apply {
|
||||
setHomeAsUpIndicator(
|
||||
if (isOpened) {
|
||||
materialR.drawable.ic_arrow_back_black_24
|
||||
appcompatR.drawable.abc_ic_ab_back_material
|
||||
} else {
|
||||
materialR.drawable.ic_search_black_24
|
||||
appcompatR.drawable.abc_ic_search_api_material
|
||||
},
|
||||
)
|
||||
setHomeActionContentDescription(
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -26,6 +25,7 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.NavItem
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
|
||||
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
|
||||
import org.koitharu.kotatsu.explore.ui.ExploreFragment
|
||||
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
|
||||
@@ -232,7 +232,7 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
private fun setNavbarIsLabeled(value: Boolean) {
|
||||
if (navBar is BottomNavigationView) {
|
||||
if (navBar is SlidingBottomNavigationView) {
|
||||
navBar.minimumHeight = navBar.resources.getDimensionPixelSize(
|
||||
if (value) {
|
||||
materialR.dimen.m3_bottom_nav_min_height
|
||||
|
||||
@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.reader.domain
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import androidx.core.net.toFile
|
||||
@@ -30,7 +32,6 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.use
|
||||
@@ -184,9 +185,10 @@ class PageLoader @Inject constructor(
|
||||
return loadPageAsync(page, force).await()
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock {
|
||||
if (uri.isZipUri()) {
|
||||
val bitmap = runInterruptible(Dispatchers.IO) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(uri.schemeSpecificPart).use { zip ->
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
context.ensureRamAtLeast(entry.size * 2)
|
||||
@@ -194,8 +196,9 @@ class PageLoader @Inject constructor(
|
||||
BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name))
|
||||
}
|
||||
}
|
||||
}.use { image ->
|
||||
cache.put(uri.toString(), image).toUri()
|
||||
}
|
||||
cache.put(uri.toString(), bitmap).toUri()
|
||||
} else {
|
||||
val file = uri.toFile()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
|
||||
@@ -127,8 +127,10 @@ class ReaderActionsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
isSliderChanged = false
|
||||
isSliderTracking = true
|
||||
if (!isSliderTracking) {
|
||||
isSliderChanged = false
|
||||
isSliderTracking = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
|
||||
@@ -105,7 +105,7 @@ class ReaderActivity :
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
|
||||
setDisplayHomeAsUp(true, false)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
touchHelper = TapGridDispatcher(this, this)
|
||||
scrollTimer = scrollTimerFactory.create(this, this)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
@@ -146,7 +146,7 @@ class ReaderActivity :
|
||||
.setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
viewModel.readerSettings.observe(this) {
|
||||
viewModel.readerSettingsProducer.observe(this) {
|
||||
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
|
||||
}
|
||||
viewModel.isZoomControlsEnabled.observe(this) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -85,6 +86,7 @@ class ReaderViewModel @Inject constructor(
|
||||
interactor: DetailsInteractor,
|
||||
deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
readerSettingsProducerFactory: ReaderSettings.Producer.Factory,
|
||||
) : ChaptersPagesViewModel(
|
||||
settings = settings,
|
||||
interactor = interactor,
|
||||
@@ -170,12 +172,8 @@ class ReaderViewModel @Inject constructor(
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
|
||||
|
||||
val readerSettings = ReaderSettings(
|
||||
parentScope = viewModelScope,
|
||||
settings = settings,
|
||||
colorFilterFlow = manga.flatMapLatest {
|
||||
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
|
||||
val readerSettingsProducer = readerSettingsProducerFactory.create(
|
||||
manga.mapNotNull { it?.id },
|
||||
)
|
||||
|
||||
val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT }
|
||||
|
||||
@@ -1,66 +1,69 @@
|
||||
package org.koitharu.kotatsu.reader.ui.config
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.view.View
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.collection.scatterSetOf
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderBackground
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
|
||||
class ReaderSettings(
|
||||
private val parentScope: CoroutineScope,
|
||||
private val settings: AppSettings,
|
||||
private val colorFilterFlow: StateFlow<ReaderColorFilter?>,
|
||||
) : MediatorLiveData<ReaderSettings>() {
|
||||
data class ReaderSettings(
|
||||
val zoomMode: ZoomMode,
|
||||
val background: ReaderBackground,
|
||||
val colorFilter: ReaderColorFilter?,
|
||||
val isReaderOptimizationEnabled: Boolean,
|
||||
val bitmapConfig: Bitmap.Config,
|
||||
val isPagesNumbersEnabled: Boolean,
|
||||
val isPagesCropEnabledStandard: Boolean,
|
||||
val isPagesCropEnabledWebtoon: Boolean,
|
||||
) {
|
||||
|
||||
private val internalObserver = InternalObserver()
|
||||
private var collectJob: Job? = null
|
||||
|
||||
val zoomMode: ZoomMode
|
||||
get() = settings.zoomMode
|
||||
|
||||
val background: ReaderBackground
|
||||
get() = settings.readerBackground
|
||||
|
||||
val colorFilter: ReaderColorFilter?
|
||||
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter
|
||||
|
||||
val isReaderOptimizationEnabled: Boolean
|
||||
get() = settings.isReaderOptimizationEnabled
|
||||
|
||||
val bitmapConfig: Bitmap.Config
|
||||
get() = if (settings.is32BitColorsEnabled) {
|
||||
private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this(
|
||||
zoomMode = settings.zoomMode,
|
||||
background = settings.readerBackground,
|
||||
colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter,
|
||||
isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled,
|
||||
bitmapConfig = if (settings.is32BitColorsEnabled) {
|
||||
Bitmap.Config.ARGB_8888
|
||||
} else {
|
||||
Bitmap.Config.RGB_565
|
||||
}
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = settings.isPagesNumbersEnabled
|
||||
},
|
||||
isPagesNumbersEnabled = settings.isPagesNumbersEnabled,
|
||||
isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD),
|
||||
isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON),
|
||||
)
|
||||
|
||||
fun applyBackground(view: View) {
|
||||
view.background = background.resolve(view.context)
|
||||
}
|
||||
|
||||
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
|
||||
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
|
||||
)
|
||||
fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) {
|
||||
isPagesCropEnabledWebtoon
|
||||
} else {
|
||||
isPagesCropEnabledStandard
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
|
||||
@@ -78,33 +81,13 @@ class ReaderSettings(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
settings.unsubscribe(internalObserver)
|
||||
collectJob?.cancel()
|
||||
collectJob = null
|
||||
}
|
||||
class Producer @AssistedInject constructor(
|
||||
@Assisted private val mangaId: Flow<Long>,
|
||||
private val settings: AppSettings,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) : MediatorStateFlow<ReaderSettings>(ReaderSettings(settings, null)) {
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
settings.subscribe(internalObserver)
|
||||
collectJob?.cancel()
|
||||
collectJob = parentScope.launch {
|
||||
colorFilterFlow.collect(internalObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValue() = this
|
||||
|
||||
private fun notifyChanged() {
|
||||
value = value
|
||||
}
|
||||
|
||||
private inner class InternalObserver :
|
||||
FlowCollector<ReaderColorFilter?>,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val settingsKeys = setOf(
|
||||
private val settingsKeys = scatterSetOf(
|
||||
AppSettings.KEY_ZOOM_MODE,
|
||||
AppSettings.KEY_PAGES_NUMBERS,
|
||||
AppSettings.KEY_READER_BACKGROUND,
|
||||
@@ -114,18 +97,38 @@ class ReaderSettings(
|
||||
AppSettings.KEY_CF_BRIGHTNESS,
|
||||
AppSettings.KEY_CF_INVERTED,
|
||||
AppSettings.KEY_CF_GRAYSCALE,
|
||||
AppSettings.KEY_READER_CROP,
|
||||
)
|
||||
private var job: Job? = null
|
||||
|
||||
override suspend fun emit(value: ReaderColorFilter?) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
notifyChanged()
|
||||
override fun onActive() {
|
||||
assert(job?.isActive != true)
|
||||
job?.cancel()
|
||||
job = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
observeImpl()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key in settingsKeys) {
|
||||
notifyChanged()
|
||||
override fun onInactive() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
private suspend fun observeImpl() {
|
||||
combine(
|
||||
mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) },
|
||||
settings.observe().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
|
||||
) { mangaCf, settingsKey ->
|
||||
ReaderSettings(settings, mangaCf)
|
||||
}.collect {
|
||||
publishValue(it)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(mangaId: Flow<Long>): Producer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,58 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.view.View
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
|
||||
import org.koitharu.kotatsu.reader.ui.pager.vm.PageState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.vm.PageViewModel
|
||||
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
|
||||
|
||||
abstract class BasePageHolder<B : ViewBinding>(
|
||||
protected val binding: B,
|
||||
loader: PageLoader,
|
||||
protected val settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
|
||||
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener, ComponentCallbacks2 {
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
protected val delegate = PageHolderDelegate(
|
||||
protected val viewModel = PageViewModel(
|
||||
loader = loader,
|
||||
readerSettings = settings,
|
||||
callback = this,
|
||||
settingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
isWebtoon = this is WebtoonHolder,
|
||||
)
|
||||
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
|
||||
protected abstract val ssiv: SubsamplingScaleImageView
|
||||
|
||||
protected val settings: ReaderSettings
|
||||
get() = viewModel.settingsProducer.value
|
||||
|
||||
val context: Context
|
||||
get() = itemView.context
|
||||
@@ -42,51 +60,139 @@ abstract class BasePageHolder<B : ViewBinding>(
|
||||
var boundData: ReaderPage? = null
|
||||
private set
|
||||
|
||||
override fun onConfigChanged() {
|
||||
settings.applyBackground(itemView)
|
||||
init {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
ssiv.bindToLifecycle(this@BasePageHolder)
|
||||
ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
|
||||
ssiv.addOnImageEventListener(viewModel)
|
||||
ssiv.addOnImageEventListener(this@BasePageHolder)
|
||||
}
|
||||
val clickListener = View.OnClickListener { v ->
|
||||
when (v.id) {
|
||||
R.id.button_retry -> viewModel.retry(
|
||||
page = boundData?.toMangaPage() ?: return@OnClickListener,
|
||||
isFromUser = true,
|
||||
)
|
||||
|
||||
R.id.button_error_details -> viewModel.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
bindingInfo.buttonRetry.setOnClickListener(clickListener)
|
||||
bindingInfo.buttonErrorDetails.setOnClickListener(clickListener)
|
||||
}
|
||||
|
||||
fun requireData(): ReaderPage {
|
||||
return checkNotNull(boundData) { "Calling requireData() before bind()" }
|
||||
@CallSuper
|
||||
protected open fun onConfigChanged(settings: ReaderSettings) {
|
||||
settings.applyBackground(itemView)
|
||||
if (settings.applyBitmapConfig(ssiv)) {
|
||||
reloadImage()
|
||||
} else if (viewModel.state.value is PageState.Shown) {
|
||||
onReady()
|
||||
}
|
||||
ssiv.applyDownSampling(isResumed())
|
||||
}
|
||||
|
||||
fun reloadImage() {
|
||||
val source = (viewModel.state.value as? PageState.Shown)?.source ?: return
|
||||
ssiv.setImage(source)
|
||||
}
|
||||
|
||||
fun bind(data: ReaderPage) {
|
||||
boundData = data
|
||||
viewModel.onBind(data.toMangaPage())
|
||||
onBind(data)
|
||||
}
|
||||
|
||||
protected abstract fun onBind(data: ReaderPage)
|
||||
@CallSuper
|
||||
protected open fun onBind(data: ReaderPage) = Unit
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
context.registerComponentCallbacks(delegate)
|
||||
context.registerComponentCallbacks(this)
|
||||
viewModel.state.observe(this, ::onStateChanged)
|
||||
viewModel.settingsProducer.observe(this, ::onConfigChanged)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (delegate.state == State.ERROR && !delegate.isLoading()) {
|
||||
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) }
|
||||
ssiv.applyDownSampling(isForeground = true)
|
||||
if (viewModel.state.value is PageState.Error && !viewModel.isLoading()) {
|
||||
boundData?.let { viewModel.retry(it.toMangaPage(), isFromUser = false) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
ssiv.applyDownSampling(isForeground = false)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
context.unregisterComponentCallbacks(delegate)
|
||||
context.unregisterComponentCallbacks(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onAttachedToWindow() {
|
||||
delegate.onAttachedToWindow()
|
||||
}
|
||||
open fun onAttachedToWindow() = Unit
|
||||
|
||||
@CallSuper
|
||||
open fun onDetachedFromWindow() {
|
||||
delegate.onDetachedFromWindow()
|
||||
}
|
||||
open fun onDetachedFromWindow() = Unit
|
||||
|
||||
@CallSuper
|
||||
open fun onRecycled() {
|
||||
delegate.onRecycle()
|
||||
viewModel.onRecycle()
|
||||
ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
final override fun onLowMemory() = onTrimMemory(TRIM_MEMORY_COMPLETE)
|
||||
|
||||
protected open fun onStateChanged(state: PageState) {
|
||||
bindingInfo.layoutError.isVisible = state is PageState.Error
|
||||
bindingInfo.layoutProgress.isGone = state.isFinalState()
|
||||
val progress = (state as? PageState.Loading)?.progress ?: -1
|
||||
if (progress in 0..100) {
|
||||
bindingInfo.progressBar.isIndeterminate = false
|
||||
bindingInfo.progressBar.setProgressCompat(progress, true)
|
||||
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
|
||||
} else {
|
||||
bindingInfo.progressBar.isIndeterminate = true
|
||||
bindingInfo.textViewStatus.setText(R.string.loading_)
|
||||
}
|
||||
when (state) {
|
||||
is PageState.Converting -> {
|
||||
bindingInfo.textViewStatus.setText(R.string.processing_)
|
||||
}
|
||||
|
||||
is PageState.Empty -> Unit
|
||||
|
||||
is PageState.Error -> {
|
||||
val e = state.error
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
is PageState.Loaded -> {
|
||||
bindingInfo.textViewStatus.setText(R.string.preparing_)
|
||||
ssiv.setImage(state.source)
|
||||
}
|
||||
|
||||
is PageState.Loading -> {
|
||||
if (state.preview != null && ssiv.getState() == null) {
|
||||
ssiv.setImage(state.preview)
|
||||
}
|
||||
}
|
||||
|
||||
is PageState.Shown -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {
|
||||
|
||||
@@ -142,7 +142,7 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
|
||||
override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import kotlin.coroutines.suspendCoroutine
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
private val loader: PageLoader,
|
||||
private val readerSettings: ReaderSettings,
|
||||
private val readerSettingsProducer: ReaderSettings.Producer,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
) : RecyclerView.Adapter<H>() {
|
||||
@@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
final override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int,
|
||||
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
|
||||
): H = onCreateViewHolder(parent, loader, readerSettingsProducer, networkState, exceptionResolver)
|
||||
|
||||
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
|
||||
differ.submitList(items) {
|
||||
@@ -69,7 +69,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
protected abstract fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
): H
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.Observer
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import java.io.IOException
|
||||
|
||||
class PageHolderDelegate(
|
||||
private val loader: PageLoader,
|
||||
private val readerSettings: ReaderSettings,
|
||||
private val callback: Callback,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
private val isWebtoon: Boolean,
|
||||
) : DefaultOnImageEventListener, Observer<ReaderSettings>, ComponentCallbacks2 {
|
||||
|
||||
private val scope = loader.loaderScope + Dispatchers.Main.immediate
|
||||
var state = State.EMPTY
|
||||
private set
|
||||
private var job: Job? = null
|
||||
private var uri: Uri? = null
|
||||
private var cachedBounds: Rect? = null
|
||||
private var error: Throwable? = null
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init
|
||||
callback.onConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun isLoading() = job?.isActive == true
|
||||
|
||||
fun onBind(page: MangaPage) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
doLoad(page, force = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry(page: MangaPage, isFromUser: Boolean) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
val e = error
|
||||
if (e != null && ExceptionResolver.canResolve(e)) {
|
||||
if (!isFromUser) {
|
||||
return@launch
|
||||
}
|
||||
exceptionResolver.resolve(e)
|
||||
}
|
||||
doLoad(page, force = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showErrorDetails(url: String?) {
|
||||
val e = error ?: return
|
||||
exceptionResolver.showErrorDetails(e, url)
|
||||
}
|
||||
|
||||
fun onAttachedToWindow() {
|
||||
readerSettings.observeForever(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromWindow() {
|
||||
readerSettings.removeObserver(this)
|
||||
}
|
||||
|
||||
fun onRecycle() {
|
||||
state = State.EMPTY
|
||||
uri = null
|
||||
cachedBounds = null
|
||||
error = null
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
if (state == State.SHOWN) {
|
||||
uri?.let {
|
||||
callback.onImageReady(it.toImageSource(cachedBounds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReady() {
|
||||
if (state >= State.LOADED) {
|
||||
state = State.SHOWING
|
||||
error = null
|
||||
callback.onImageShowing(readerSettings, isPreview = false)
|
||||
} else if (state == State.LOADING_WITH_PREVIEW) {
|
||||
callback.onImageShowing(readerSettings, isPreview = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageLoaded() {
|
||||
if (state >= State.LOADED) {
|
||||
state = State.SHOWN
|
||||
error = null
|
||||
callback.onImageShown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
if (state < State.LOADED) {
|
||||
// ignore preview error
|
||||
return
|
||||
}
|
||||
val uri = this.uri
|
||||
error = e
|
||||
if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
|
||||
tryConvert(uri, e)
|
||||
} else {
|
||||
state = State.ERROR
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChanged(value: ReaderSettings) {
|
||||
if (state == State.SHOWN) {
|
||||
callback.onImageShowing(readerSettings, isPreview = false)
|
||||
}
|
||||
callback.onConfigChanged()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onLowMemory() = Unit
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
callback.onTrimMemory()
|
||||
}
|
||||
|
||||
private fun tryConvert(uri: Uri, e: Exception) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.join()
|
||||
state = State.CONVERTING
|
||||
try {
|
||||
val newUri = loader.convertBimap(uri)
|
||||
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(newUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state = State.CONVERTED
|
||||
callback.onImageReady(newUri.toImageSource(cachedBounds))
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (e2: Throwable) {
|
||||
e2.printStackTrace()
|
||||
e.addSuppressed(e2)
|
||||
state = State.ERROR
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
|
||||
state = State.LOADING
|
||||
error = null
|
||||
callback.onLoadingStarted()
|
||||
launch {
|
||||
val preview = loader.loadPreview(data) ?: return@launch
|
||||
if (state == State.LOADING) {
|
||||
state = State.LOADING_WITH_PREVIEW
|
||||
callback.onPreviewReady(preview)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val task = withContext(Dispatchers.Default) {
|
||||
loader.loadPageAsync(data, force)
|
||||
}
|
||||
val progressObserver = observeProgress(this, task.progressAsFlow())
|
||||
val file = task.await()
|
||||
progressObserver.cancelAndJoin()
|
||||
uri = file
|
||||
state = State.LOADED
|
||||
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(checkNotNull(uri))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
callback.onImageReady(checkNotNull(uri).toImageSource(cachedBounds))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
state = State.ERROR
|
||||
error = e
|
||||
callback.onError(e)
|
||||
if (e is IOException && !networkState.value) {
|
||||
networkState.awaitForConnection()
|
||||
retry(data, isFromUser = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
|
||||
.debounce(250)
|
||||
.onEach { callback.onProgressChanged((100 * it).toInt()) }
|
||||
.launchIn(scope)
|
||||
|
||||
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
|
||||
val source = ImageSource.uri(this)
|
||||
return if (bounds != null) {
|
||||
source.region(bounds)
|
||||
} else {
|
||||
source
|
||||
}
|
||||
}
|
||||
|
||||
enum class State {
|
||||
EMPTY, LOADING, LOADING_WITH_PREVIEW, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun onLoadingStarted()
|
||||
|
||||
fun onError(e: Throwable)
|
||||
|
||||
fun onPreviewReady(source: ImageSource)
|
||||
|
||||
fun onImageReady(source: ImageSource)
|
||||
|
||||
fun onImageShowing(settings: ReaderSettings, isPreview: Boolean)
|
||||
|
||||
fun onImageShown()
|
||||
|
||||
fun onProgressChanged(progress: Int)
|
||||
|
||||
fun onConfigChanged()
|
||||
|
||||
fun onTrimMemory()
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,17 @@ class DoublePageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
|
||||
) : PageHolder(
|
||||
owner = owner,
|
||||
binding = binding,
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
) {
|
||||
|
||||
private val isEven: Boolean
|
||||
get() = bindingAdapterPosition and 1 == 0
|
||||
@@ -35,7 +42,7 @@ class DoublePageHolder(
|
||||
.gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
|
||||
override fun onReady() {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * maxOf(
|
||||
width / sWidth.toFloat(),
|
||||
|
||||
@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class DoublePagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<DoublePageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<DoublePageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = DoublePageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -90,7 +90,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
||||
override fun onCreateAdapter() = DoublePagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.RoundedCorner
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.isRtl
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
@@ -17,17 +25,24 @@ class ReversedPageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
|
||||
) : PageHolder(
|
||||
owner = owner,
|
||||
binding = binding,
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
) {
|
||||
|
||||
init {
|
||||
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
|
||||
.gravity = Gravity.START or Gravity.BOTTOM
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
|
||||
override fun onReady() {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * maxOf(
|
||||
width / sWidth.toFloat(),
|
||||
|
||||
@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class ReversedPagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<ReversedPageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = ReversedPageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ class ReversedReaderFragment : BasePagerReaderFragment() {
|
||||
override fun onCreateAdapter() = ReversedPagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -2,22 +2,28 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.PointF
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.RoundedCorner
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
@@ -27,77 +33,48 @@ open class PageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
|
||||
View.OnClickListener,
|
||||
ZoomControl.ZoomControlListener {
|
||||
) : BasePageHolder<ItemPageBinding>(
|
||||
binding = binding,
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
lifecycleOwner = owner,
|
||||
), ZoomControl.ZoomControlListener, OnApplyWindowInsetsListener {
|
||||
|
||||
override val ssiv = binding.ssiv
|
||||
|
||||
init {
|
||||
binding.ssiv.bindToLifecycle(owner)
|
||||
binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
|
||||
binding.ssiv.addOnImageEventListener(delegate)
|
||||
@Suppress("LeakingThis")
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
@Suppress("LeakingThis")
|
||||
bindingInfo.buttonErrorDetails.setOnClickListener(this)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.ssiv.applyDownSampling(isForeground = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.ssiv.applyDownSampling(isForeground = false)
|
||||
}
|
||||
|
||||
override fun onConfigChanged() {
|
||||
super.onConfigChanged()
|
||||
if (settings.applyBitmapConfig(binding.ssiv)) {
|
||||
delegate.reload()
|
||||
override fun onApplyWindowInsets(
|
||||
v: View,
|
||||
insets: WindowInsetsCompat
|
||||
): WindowInsetsCompat {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
insets.toWindowInsets()?.let {
|
||||
applyRoundedCorners(it)
|
||||
}
|
||||
}
|
||||
binding.ssiv.applyDownSampling(isResumed())
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onConfigChanged(settings: ReaderSettings) {
|
||||
super.onConfigChanged(settings)
|
||||
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(data: ReaderPage) {
|
||||
delegate.onBind(data.toMangaPage())
|
||||
super.onBind(data)
|
||||
binding.textViewNumber.text = (data.index + 1).toString()
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
super.onRecycled()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onLoadingStarted() {
|
||||
bindingInfo.layoutError.isVisible = false
|
||||
bindingInfo.progressBar.show()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onProgressChanged(progress: Int) {
|
||||
if (progress in 0..100) {
|
||||
bindingInfo.progressBar.isIndeterminate = false
|
||||
bindingInfo.progressBar.setProgressCompat(progress, true)
|
||||
} else {
|
||||
bindingInfo.progressBar.isIndeterminate = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreviewReady(source: ImageSource) {
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageReady(source: ImageSource) {
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
|
||||
override fun onReady() {
|
||||
binding.ssiv.maxScale = 2f * maxOf(
|
||||
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
|
||||
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
|
||||
@@ -137,31 +114,6 @@ open class PageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageShown() {
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
override fun onTrimMemory() {
|
||||
// TODO https://developer.android.com/topic/performance/memory
|
||||
}
|
||||
|
||||
final override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
|
||||
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
override fun onZoomIn() {
|
||||
scaleBy(1.2f)
|
||||
}
|
||||
@@ -170,6 +122,29 @@ open class PageHolder(
|
||||
scaleBy(0.8f)
|
||||
}
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
protected open fun applyRoundedCorners(insets: WindowInsets) {
|
||||
binding.textViewNumber.updateLayoutParams<FrameLayout.LayoutParams> {
|
||||
val baseMargin = context.resources.getDimensionPixelOffset(R.dimen.margin_small)
|
||||
val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection)
|
||||
val corner = when {
|
||||
absoluteGravity and Gravity.LEFT == Gravity.LEFT -> {
|
||||
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)
|
||||
}
|
||||
|
||||
absoluteGravity and Gravity.RIGHT == Gravity.RIGHT -> {
|
||||
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
setMargins(baseMargin + (corner?.radius ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaleBy(factor: Float) {
|
||||
val ssiv = binding.ssiv
|
||||
val center = ssiv.getCenter() ?: return
|
||||
|
||||
@@ -13,22 +13,27 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class PagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<PageHolder>(
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = PageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.vm
|
||||
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
|
||||
sealed class PageState {
|
||||
|
||||
data object Empty : PageState()
|
||||
|
||||
data class Loading(
|
||||
val preview: ImageSource?,
|
||||
val progress: Int,
|
||||
) : PageState()
|
||||
|
||||
data class Loaded(
|
||||
val source: ImageSource,
|
||||
val isConverted: Boolean,
|
||||
) : PageState()
|
||||
|
||||
class Converting() : PageState()
|
||||
|
||||
data class Shown(
|
||||
val source: ImageSource,
|
||||
val isConverted: Boolean,
|
||||
) : PageState()
|
||||
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
) : PageState()
|
||||
|
||||
fun isFinalState(): Boolean = this is Error || this is Shown
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.vm
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.throttle
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
|
||||
class PageViewModel(
|
||||
private val loader: PageLoader,
|
||||
val settingsProducer: ReaderSettings.Producer,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
private val isWebtoon: Boolean,
|
||||
) : DefaultOnImageEventListener {
|
||||
|
||||
private val scope = loader.loaderScope + Dispatchers.Main.immediate
|
||||
private var job: Job? = null
|
||||
private var cachedBounds: Rect? = null
|
||||
|
||||
val state = MutableStateFlow<PageState>(PageState.Empty)
|
||||
|
||||
fun isLoading() = job?.isActive == true
|
||||
|
||||
fun onBind(page: MangaPage) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
doLoad(page, force = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry(page: MangaPage, isFromUser: Boolean) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
val e = (state.value as? PageState.Error)?.error
|
||||
if (e != null && ExceptionResolver.canResolve(e)) {
|
||||
if (isFromUser) {
|
||||
exceptionResolver.resolve(e)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
doLoad(page, force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showErrorDetails(url: String?) {
|
||||
val e = (state.value as? PageState.Error)?.error ?: return
|
||||
exceptionResolver.showErrorDetails(e, url)
|
||||
}
|
||||
|
||||
fun onRecycle() {
|
||||
state.value = PageState.Empty
|
||||
cachedBounds = null
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
override fun onImageLoaded() {
|
||||
state.update { currentState ->
|
||||
if (currentState is PageState.Loaded) {
|
||||
PageState.Shown(currentState.source, currentState.isConverted)
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
|
||||
state.update { currentState ->
|
||||
if (currentState is PageState.Loaded) {
|
||||
val uri = (currentState.source as? ImageSource.Uri)?.uri
|
||||
if (!currentState.isConverted && uri != null && e is IOException) {
|
||||
tryConvert(uri, e)
|
||||
PageState.Converting()
|
||||
} else {
|
||||
PageState.Error(e)
|
||||
}
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryConvert(uri: Uri, e: Exception) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob?.join()
|
||||
state.value = PageState.Converting()
|
||||
try {
|
||||
val newUri = loader.convertBimap(uri)
|
||||
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(newUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state.value = PageState.Loaded(newUri.toImageSource(cachedBounds), isConverted = true)
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (e2: Throwable) {
|
||||
e2.printStackTrace()
|
||||
e.addSuppressed(e2)
|
||||
state.value = PageState.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
|
||||
state.value = PageState.Loading(null, -1)
|
||||
val previewJob = launch {
|
||||
val preview = loader.loadPreview(data) ?: return@launch
|
||||
state.update {
|
||||
if (it is PageState.Loading) it.copy(preview = preview) else it
|
||||
}
|
||||
}
|
||||
try {
|
||||
val task = loader.loadPageAsync(data, force)
|
||||
val progressObserver = observeProgress(this, task.progressAsFlow())
|
||||
val uri = task.await()
|
||||
progressObserver.cancelAndJoin()
|
||||
previewJob.cancel()
|
||||
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = false)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
state.value = PageState.Error(e)
|
||||
if (e is IOException && !networkState.value) {
|
||||
networkState.awaitForConnection()
|
||||
retry(data, isFromUser = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
|
||||
.throttle(250)
|
||||
.onEach {
|
||||
val progressValue = (100 * it).toInt()
|
||||
state.update { currentState ->
|
||||
if (currentState is PageState.Loading) {
|
||||
currentState.copy(progress = progressValue)
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}.launchIn(scope)
|
||||
|
||||
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
|
||||
val source = ImageSource.uri(this)
|
||||
return if (bounds != null) {
|
||||
source.region(bounds)
|
||||
} else {
|
||||
source
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class WebtoonAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<WebtoonHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = WebtoonHolder(
|
||||
@@ -32,7 +32,7 @@ class WebtoonAdapter(
|
||||
false,
|
||||
),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -1,101 +1,39 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
class WebtoonHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageWebtoonBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
|
||||
View.OnClickListener {
|
||||
) : BasePageHolder<ItemPageWebtoonBinding>(
|
||||
binding = binding,
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
lifecycleOwner = owner,
|
||||
) {
|
||||
|
||||
override val ssiv = binding.ssiv
|
||||
|
||||
private var scrollToRestore = 0
|
||||
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
|
||||
|
||||
init {
|
||||
binding.ssiv.bindToLifecycle(owner)
|
||||
binding.ssiv.addOnImageEventListener(delegate)
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
bindingInfo.buttonErrorDetails.setOnClickListener(this)
|
||||
bindingInfo.progressBar.setVisibilityAfterHide(View.GONE)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.ssiv.applyDownSampling(isForeground = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
binding.ssiv.applyDownSampling(isForeground = false)
|
||||
}
|
||||
|
||||
override fun onConfigChanged() {
|
||||
super.onConfigChanged()
|
||||
if (settings.applyBitmapConfig(binding.ssiv)) {
|
||||
delegate.reload()
|
||||
}
|
||||
binding.ssiv.applyDownSampling(isResumed())
|
||||
}
|
||||
|
||||
override fun onBind(data: ReaderPage) {
|
||||
delegate.onBind(data.toMangaPage())
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
super.onRecycled()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
goneOnInvisibleListener.attach()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
goneOnInvisibleListener.detach()
|
||||
}
|
||||
|
||||
override fun onLoadingStarted() {
|
||||
bindingInfo.layoutError.isVisible = false
|
||||
bindingInfo.progressBar.show()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onProgressChanged(progress: Int) {
|
||||
if (progress in 0..100) {
|
||||
bindingInfo.progressBar.isIndeterminate = false
|
||||
bindingInfo.progressBar.setProgressCompat(progress, true)
|
||||
} else {
|
||||
bindingInfo.progressBar.isIndeterminate = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreviewReady(source: ImageSource) = Unit
|
||||
|
||||
override fun onImageReady(source: ImageSource) {
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
|
||||
override fun onReady() {
|
||||
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
|
||||
with(binding.ssiv) {
|
||||
scrollTo(
|
||||
@@ -109,31 +47,6 @@ class WebtoonHolder(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageShown() {
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
override fun onTrimMemory() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
|
||||
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
fun getScrollY() = binding.ssiv.getScroll()
|
||||
|
||||
fun restoreScroll(scroll: Int) {
|
||||
|
||||
@@ -67,7 +67,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
rv.addItemDecoration(WebtoonGapsDecoration())
|
||||
}
|
||||
}
|
||||
viewModel.readerSettings.observe(viewLifecycleOwner) {
|
||||
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
|
||||
it.applyBackground(binding.root)
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
override fun onCreateAdapter() = WebtoonAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
@@ -87,15 +88,15 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String?) {
|
||||
if (url.isNullOrEmpty()) {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
if (url?.isHttpUrl() == true) {
|
||||
router.openBrowser(
|
||||
url = url,
|
||||
source = viewModel.source,
|
||||
title = viewModel.source.getTitle(requireContext()),
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class MangaSearchRepository @Inject constructor(
|
||||
|
||||
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
|
||||
return when {
|
||||
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, it.tags) }
|
||||
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, emptyList()) }
|
||||
source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
|
||||
else -> db.getMangaDao().searchByTitle("%$query%", limit)
|
||||
}.let {
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.dropWhile
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -67,7 +68,7 @@ class SearchViewModel @Inject constructor(
|
||||
|
||||
val list: StateFlow<List<ListModel>> = combine(
|
||||
results,
|
||||
isLoading,
|
||||
isLoading.dropWhile { !it },
|
||||
includeDisabledSources,
|
||||
) { list, loading, includeDisabled ->
|
||||
when {
|
||||
|
||||
@@ -9,11 +9,13 @@ import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.os.VoiceInputContract
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
|
||||
import javax.inject.Inject
|
||||
@@ -49,19 +51,16 @@ class SearchSuggestionFragment :
|
||||
binding.root.adapter = adapter
|
||||
binding.root.setHasFixedSize(true)
|
||||
viewModel.suggestion.observe(viewLifecycleOwner, adapter)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.root, this))
|
||||
ItemTouchHelper(SearchSuggestionItemCallback(this))
|
||||
.attachToRecyclerView(binding.root)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(
|
||||
barsInsets.left,
|
||||
0,
|
||||
barsInsets.right,
|
||||
barsInsets.bottom,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()
|
||||
val barsInsets = insets.getInsets(typeMask)
|
||||
v.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom)
|
||||
return insets.consumeAll(typeMask)
|
||||
}
|
||||
|
||||
override fun onRemoveQuery(query: String) {
|
||||
|
||||
@@ -21,11 +21,12 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
import javax.inject.Inject
|
||||
@@ -87,7 +88,7 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
if (invalidateOnResume) {
|
||||
if (invalidateOnResume || suggestionJob?.isActive != true) {
|
||||
invalidateOnResume = false
|
||||
setupSuggestion()
|
||||
}
|
||||
@@ -120,62 +121,114 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
enabledSources: Set<String>,
|
||||
types: Set<SearchSuggestionType>,
|
||||
): List<SearchSuggestionItem> = coroutineScope {
|
||||
val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) {
|
||||
async { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) }
|
||||
listOfNotNull(
|
||||
if (SearchSuggestionType.GENRES in types) {
|
||||
async { getTags(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.MANGA in types) {
|
||||
async { getManga(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.QUERIES_RECENT in types) {
|
||||
async { getRecentQueries(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.QUERIES_SUGGEST in types) {
|
||||
async { getQueryHints(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.SOURCES in types) {
|
||||
async { getSources(searchQuery, enabledSources) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.RECENT_SOURCES in types) {
|
||||
async { getRecentSources(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.AUTHORS in types) {
|
||||
async {
|
||||
getAuthors(searchQuery)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
).flatMap { it.await() }
|
||||
}
|
||||
|
||||
private suspend fun getAuthors(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS)
|
||||
.map { SearchSuggestionItem.Author(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getQueryHints(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS)
|
||||
.map { SearchSuggestionItem.Hint(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getRecentQueries(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS)
|
||||
.map { SearchSuggestionItem.RecentQuery(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getTags(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
val tags = repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null)
|
||||
if (tags.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
null
|
||||
listOf(SearchSuggestionItem.Tags(mapTags(tags)))
|
||||
}
|
||||
val hintsDeferred = if (SearchSuggestionType.QUERIES_SUGGEST in types) {
|
||||
async { repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getManga(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null)
|
||||
if (manga.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
null
|
||||
listOf(SearchSuggestionItem.MangaList(manga))
|
||||
}
|
||||
val authorsDeferred = if (SearchSuggestionType.AUTHORS in types) {
|
||||
async { repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tagsDeferred = if (SearchSuggestionType.GENRES in types) {
|
||||
async { repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val mangaDeferred = if (SearchSuggestionType.MANGA in types) {
|
||||
async { repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sources = if (SearchSuggestionType.SOURCES in types) {
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getSources(searchQuery: String, enabledSources: Set<String>): List<SearchSuggestionItem> =
|
||||
runCatchingCancellable {
|
||||
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sourcesTipsDeferred = if (searchQuery.isEmpty() && SearchSuggestionType.RECENT_SOURCES in types) {
|
||||
async { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) }
|
||||
} else {
|
||||
null
|
||||
.map { SearchSuggestionItem.Source(it, it.name in enabledSources) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
val tags = tagsDeferred?.await()
|
||||
val mangaList = mangaDeferred?.await()
|
||||
val queries = queriesDeferred?.await()
|
||||
val hints = hintsDeferred?.await()
|
||||
val authors = authorsDeferred?.await()
|
||||
val sourcesTips = sourcesTipsDeferred?.await()
|
||||
|
||||
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
add(SearchSuggestionItem.Tags(mapTags(tags)))
|
||||
}
|
||||
if (!mangaList.isNullOrEmpty()) {
|
||||
add(SearchSuggestionItem.MangaList(mangaList))
|
||||
}
|
||||
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it.name in enabledSources) }
|
||||
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
|
||||
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
|
||||
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
|
||||
sourcesTips?.mapTo(this) { SearchSuggestionItem.SourceTip(it) }
|
||||
private suspend fun getRecentSources(searchQuery: String): List<SearchSuggestionItem> = if (searchQuery.isEmpty()) {
|
||||
runCatchingCancellable {
|
||||
repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS)
|
||||
.map { SearchSuggestionItem.SourceTip(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->
|
||||
|
||||
@@ -23,5 +23,6 @@ class SearchSuggestionAdapter(
|
||||
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(searchSuggestionQueryHintAD(listener))
|
||||
.addDelegate(searchSuggestionAuthorAD(listener))
|
||||
.addDelegate(searchSuggestionTextAD())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import android.widget.TextView
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
fun searchSuggestionTextAD() = adapterDelegate<SearchSuggestionItem.Text, SearchSuggestionItem>(
|
||||
R.layout.item_search_suggestion_text,
|
||||
) {
|
||||
|
||||
bind {
|
||||
val tv = itemView as TextView
|
||||
val isError = item.error != null
|
||||
tv.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
if (isError) R.drawable.ic_error_small else 0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if (item.textResId != 0) {
|
||||
tv.setText(item.textResId)
|
||||
} else {
|
||||
tv.text = item.error?.getDisplayMessage(tv.resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
@@ -93,4 +94,15 @@ sealed interface SearchSuggestionItem : ListModel {
|
||||
return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED
|
||||
}
|
||||
}
|
||||
|
||||
data class Text(
|
||||
@StringRes val textResId: Int,
|
||||
val error: Throwable?,
|
||||
) : SearchSuggestionItem {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean = other is Text
|
||||
&& textResId == other.textResId
|
||||
&& error?.javaClass == other.error?.javaClass
|
||||
&& error?.message == other.error?.message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import org.koitharu.kotatsu.settings.search.SettingsItem
|
||||
import org.koitharu.kotatsu.settings.search.SettingsSearchFragment
|
||||
import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel
|
||||
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||
@@ -57,7 +56,7 @@ class SettingsActivity :
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
|
||||
setDisplayHomeAsUp(true, false)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
val fm = supportFragmentManager
|
||||
val currentFragment = fm.findFragmentById(R.id.container)
|
||||
if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) {
|
||||
@@ -151,7 +150,7 @@ class SettingsActivity :
|
||||
AppRouter.ACTION_PROXY -> ProxySettingsFragment()
|
||||
AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment()
|
||||
AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance(
|
||||
MangaSource(intent.getStringExtra(EXTRA_SOURCE)),
|
||||
MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)),
|
||||
)
|
||||
|
||||
AppRouter.ACTION_MANAGE_SOURCES -> SourcesManageFragment()
|
||||
|
||||
@@ -37,7 +37,7 @@ import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.EnumSet
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestoreService : CoroutineIntentService() {
|
||||
@@ -219,7 +219,7 @@ class RestoreService : CoroutineIntentService() {
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getCancelIntent(),
|
||||
).build()
|
||||
|
||||
@@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
@@ -121,10 +122,8 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
|
||||
private const val KEY_AUTH = "auth"
|
||||
private const val KEY_ENABLE = "enable"
|
||||
|
||||
const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) {
|
||||
putString(EXTRA_SOURCE, source.name)
|
||||
putString(AppRouter.KEY_SOURCE, source.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -32,7 +33,7 @@ class SourceSettingsViewModel @Inject constructor(
|
||||
private val mangaSourcesRepository: MangaSourcesRepository,
|
||||
) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
val source = MangaSource(savedStateHandle.get<String>(SourceSettingsFragment.EXTRA_SOURCE))
|
||||
val source = MangaSource(savedStateHandle.get<String>(AppRouter.KEY_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
|
||||
@@ -14,46 +14,34 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BaseBrowserActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserCallback
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var authProvider: MangaParserAuthProvider
|
||||
|
||||
override fun onCreate2(savedInstanceState: Bundle?) {
|
||||
val source = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||
if (source !is MangaParserSource) {
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
if (repository == null) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
val repository = mangaRepositoryFactory.create(source) as? ParserMangaRepository
|
||||
authProvider = (repository)?.getAuthProvider() ?: run {
|
||||
authProvider = repository.getAuthProvider() ?: run {
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(R.string.auth_not_supported_by, source.title),
|
||||
getString(R.string.auth_not_supported_by, source.getTitle(this)),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
setDisplayHomeAsUp(true, true)
|
||||
viewBinding.webView.configureForParser(repository.getRequestHeaders()[CommonHeaders.USER_AGENT])
|
||||
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
@@ -63,7 +51,7 @@ class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
|
||||
if (savedInstanceState == null) {
|
||||
val url = authProvider.authUrl
|
||||
onTitleChanged(
|
||||
source.title,
|
||||
source.getTitle(this@SourceAuthActivity),
|
||||
getString(R.string.loading_),
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
@@ -92,13 +80,10 @@ class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<MangaSource, Boolean>() {
|
||||
override fun createIntent(context: Context, input: MangaSource): Intent {
|
||||
return AppRouter.sourceAuthIntent(context, input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == RESULT_OK
|
||||
}
|
||||
override fun createIntent(context: Context, input: MangaSource) = AppRouter.sourceAuthIntent(context, input)
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?) = resultCode == RESULT_OK
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -17,6 +17,7 @@ import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -117,7 +118,7 @@ class SourcesManageFragment :
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
|
||||
(activity as? SettingsActivity)?.openFragment(
|
||||
fragmentClass = SourceSettingsFragment::class.java,
|
||||
args = Bundle(1).apply { putString(SourceSettingsFragment.EXTRA_SOURCE, item.source.name) },
|
||||
args = Bundle(1).apply { putString(AppRouter.KEY_SOURCE, item.source.name) },
|
||||
isFromRoot = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ class MangaDirectorySelectViewModel @Inject constructor(
|
||||
fun onCustomDirectoryPicked(uri: Uri) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = requireNotNull(storageManager.resolveUri(uri)) {
|
||||
"Cannot resolve file name of \"$uri\""
|
||||
}
|
||||
val dir = storageManager.resolveUri(uri)
|
||||
if (!dir.isWriteable()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
|
||||
@@ -36,9 +36,7 @@ class MangaDirectoriesViewModel @Inject constructor(
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
loadingJob?.cancelAndJoin()
|
||||
storageManager.takePermissions(uri)
|
||||
val dir = requireNotNull(storageManager.resolveUri(uri)) {
|
||||
"Cannot resolve file name of \"$uri\""
|
||||
}
|
||||
val dir = storageManager.resolveUri(uri)
|
||||
if (!dir.canRead()) {
|
||||
throw AccessDeniedException(dir)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.room.Update
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
|
||||
@@ -33,12 +34,10 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
|
||||
abstract suspend fun getRandom(): SuggestionWithManga?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT :limit")
|
||||
abstract suspend fun getRandom(limit: Int): List<SuggestionWithManga>
|
||||
open suspend fun getRandom(limit: Int): List<MangaWithTags> {
|
||||
val ids = getRandomIds(limit)
|
||||
return getByIds(ids)
|
||||
}
|
||||
|
||||
@Query("SELECT COUNT(*) FROM suggestions")
|
||||
abstract suspend fun count(): Int
|
||||
@@ -68,6 +67,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
}
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (:ids)")
|
||||
protected abstract suspend fun getByIds(ids: LongArray): List<MangaWithTags>
|
||||
|
||||
@Query("SELECT manga_id FROM suggestions ORDER BY RANDOM() LIMIT :limit")
|
||||
protected abstract suspend fun getRandomIds(limit: Int): LongArray
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [SuggestionEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<SuggestionWithManga>>
|
||||
@@ -75,7 +80,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})"
|
||||
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}"
|
||||
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${
|
||||
sqlEscapeString(
|
||||
option.mangaSource.name,
|
||||
)
|
||||
}"
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
@@ -34,10 +33,6 @@ class SuggestionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getRandom(): Manga? {
|
||||
return db.getSuggestionDao().getRandom()?.toManga()
|
||||
}
|
||||
|
||||
suspend fun getRandomList(limit: Int): List<Manga> {
|
||||
return db.getSuggestionDao().getRandom(limit).map {
|
||||
it.toManga()
|
||||
@@ -80,5 +75,5 @@ class SuggestionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun SuggestionWithManga.toManga() = manga.toManga(tags.toMangaTags(), null)
|
||||
private fun SuggestionWithManga.toManga() = manga.toManga(emptySet(), null)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@HiltWorker
|
||||
class SuggestionsWorker @AssistedInject constructor(
|
||||
@@ -137,7 +137,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
false,
|
||||
),
|
||||
).addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
workManager.createCancelPendingIntent(id),
|
||||
)
|
||||
@@ -352,7 +352,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
)
|
||||
setAutoCancel(true)
|
||||
setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
|
||||
setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC)
|
||||
setVisibility(if (manga.isNsfw()) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PRIVATE)
|
||||
setShortcutId(manga.id.toString())
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
|
||||
|
||||
@@ -27,17 +27,17 @@ abstract class TracksDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun find(mangaId: Long): TrackEntity?
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun findNewChapters(mangaId: Long): Int?
|
||||
@Query("SELECT IFNULL(chapters_new,0) FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun findNewChapters(mangaId: Long): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM tracks")
|
||||
abstract suspend fun getTracksCount(): Int
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks")
|
||||
abstract fun observeNewChapters(): Flow<List<Int>>
|
||||
@Query("SELECT COUNT(*) FROM tracks WHERE chapters_new > 0")
|
||||
abstract fun observeUpdateMangaCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
|
||||
@Query("SELECT IFNULL(chapters_new, 0) FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract fun observeNewChapters(mangaId: Long): Flow<Int>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC")
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
@@ -39,16 +38,16 @@ class TrackingRepository @Inject constructor(
|
||||
private var isGcCalled = AtomicBoolean(false)
|
||||
|
||||
suspend fun getNewChaptersCount(mangaId: Long): Int {
|
||||
return db.getTracksDao().findNewChapters(mangaId) ?: 0
|
||||
return db.getTracksDao().findNewChapters(mangaId)
|
||||
}
|
||||
|
||||
fun observeNewChaptersCount(mangaId: Long): Flow<Int> {
|
||||
return db.getTracksDao().observeNewChapters(mangaId).map { it ?: 0 }
|
||||
return db.getTracksDao().observeNewChapters(mangaId)
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
fun observeUpdatedMangaCount(): Flow<Int> {
|
||||
return db.getTracksDao().observeNewChapters().map { list -> list.count { it > 0 } }
|
||||
return db.getTracksDao().observeUpdateMangaCount()
|
||||
.onStart { gcIfNotCalled() }
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class TrackerDebugViewModel @Inject constructor(
|
||||
|
||||
val content = db.getTracksDao().observeAll()
|
||||
.map { it.toUiList() }
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
|
||||
|
||||
@@ -70,7 +70,7 @@ import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import kotlin.math.roundToInt
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@HiltWorker
|
||||
class TrackWorker @AssistedInject constructor(
|
||||
@@ -215,7 +215,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
),
|
||||
)
|
||||
addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
workManager.createCancelPendingIntent(id),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_PRIVATE
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
@@ -17,12 +17,14 @@ import coil3.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.core.model.getLocalizedTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import javax.inject.Inject
|
||||
@@ -51,7 +53,7 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
if (newChapters.isEmpty() || !applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return null
|
||||
}
|
||||
if (manga.isNsfw && (settings.isTrackerNsfwDisabled || settings.isNsfwContentDisabled)) {
|
||||
if (manga.isNsfw() && (settings.isTrackerNsfwDisabled || settings.isNsfwContentDisabled)) {
|
||||
return null
|
||||
}
|
||||
val id = manga.url.hashCode()
|
||||
@@ -92,7 +94,7 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
false,
|
||||
),
|
||||
)
|
||||
setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC)
|
||||
setVisibility(if (manga.isNsfw()) VISIBILITY_SECRET else VISIBILITY_PRIVATE)
|
||||
setShortcutId(manga.id.toString())
|
||||
applyCommonSettings(this)
|
||||
}
|
||||
@@ -127,6 +129,13 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
setNumber(newChaptersCount)
|
||||
setGroup(GROUP_NEW_CHAPTERS)
|
||||
setGroupSummary(true)
|
||||
setVisibility(
|
||||
if (notifications.any { it.manga.isNsfw() }) {
|
||||
VISIBILITY_SECRET
|
||||
} else {
|
||||
VISIBILITY_PRIVATE
|
||||
},
|
||||
)
|
||||
val intent = AppRouter.mangaUpdatesIntent(applicationContext)
|
||||
setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.4" android:color="?android:colorBackground" />
|
||||
</selector>
|
||||
4
app/src/main/res/color/bg_background_transparency.xml
Normal file
4
app/src/main/res/color/bg_background_transparency.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.4" android:color="@color/kotatsu_background" />
|
||||
</selector>
|
||||
10
app/src/main/res/drawable/bg_rounded_transparency.xml
Normal file
10
app/src/main/res/drawable/bg_rounded_transparency.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="4dp" />
|
||||
|
||||
<solid android:color="@color/bg_background_transparency" />
|
||||
|
||||
</shape>
|
||||
@@ -133,7 +133,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_normal"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/invert_colors"
|
||||
android:text="@string/grayscale"
|
||||
android:textAppearance="?textAppearanceTitleMedium"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/guideline_vertical"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_margin="8dp"
|
||||
android:layout_margin="@dimen/margin_small"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
|
||||
14
app/src/main/res/layout/item_search_suggestion_text.xml
Normal file
14
app/src/main/res/layout/item_search_suggestion_text.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="@dimen/screen_padding"
|
||||
android:paddingVertical="@dimen/margin_small"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
tools:drawableStart="@drawable/ic_error_small"
|
||||
tools:text="@string/error_corrupted_file" />
|
||||
@@ -12,7 +12,6 @@
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginHorizontal="@dimen/screen_padding"
|
||||
android:layout_marginBottom="-12dp"
|
||||
app:cardBackgroundColor="?colorBackgroundFloating"
|
||||
app:layout_constraintBottom_toBottomOf="@id/textView_progress_label"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -230,7 +229,7 @@
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.Material3.LinearProgressIndicator"
|
||||
style="?linearProgressIndicatorStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_normal"
|
||||
@@ -242,6 +241,7 @@
|
||||
app:layout_constraintEnd_toStartOf="@id/textView_progress"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier_table"
|
||||
app:layout_constraintTop_toTopOf="@id/textView_progress_label"
|
||||
app:trackColor="?android:colorBackground"
|
||||
tools:progress="12" />
|
||||
|
||||
<TextView
|
||||
|
||||
@@ -5,27 +5,50 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:parentTag="android.widget.FrameLayout">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:max="100"
|
||||
app:hideAnimationBehavior="escape"
|
||||
app:showAnimationBehavior="none" />
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/bg_rounded_transparency"
|
||||
android:gravity="center"
|
||||
android:padding="4dp"
|
||||
android:textAppearance="?textAppearanceBodyLarge"
|
||||
tools:text="72%" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_error"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="60dp"
|
||||
android:layout_marginEnd="60dp"
|
||||
android:layout_marginStart="56dp"
|
||||
android:layout_marginEnd="56dp"
|
||||
android:background="@drawable/bg_card"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/screen_padding"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
tools:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_error"
|
||||
|
||||
@@ -1,22 +1,107 @@
|
||||
yaoi
|
||||
yuri
|
||||
amputation
|
||||
amputee
|
||||
anal birth
|
||||
anal torture
|
||||
bdsm
|
||||
beast
|
||||
beastiality
|
||||
bestiality
|
||||
birth
|
||||
blackmail
|
||||
blood
|
||||
body horror
|
||||
bondage
|
||||
boys' love
|
||||
brother
|
||||
bukkake
|
||||
cannibalism
|
||||
cbt
|
||||
choking
|
||||
coprophagia
|
||||
degradation
|
||||
diapers
|
||||
drugs
|
||||
egg laying
|
||||
electrical play
|
||||
electro
|
||||
electro play
|
||||
enema
|
||||
extreme
|
||||
father
|
||||
femdom
|
||||
force
|
||||
full censorship
|
||||
furry
|
||||
futanari
|
||||
gang rape
|
||||
gangbang
|
||||
gangbang rape
|
||||
gender bender
|
||||
girls' love
|
||||
guro
|
||||
human pet
|
||||
humiliation
|
||||
hypno
|
||||
incest
|
||||
inflation
|
||||
insect
|
||||
inseki
|
||||
knife play
|
||||
loli
|
||||
lolicon
|
||||
machine
|
||||
mind break
|
||||
mindbreak
|
||||
molestation
|
||||
mosaic
|
||||
mother
|
||||
mutilation
|
||||
necrophila
|
||||
necrophilia
|
||||
netorase
|
||||
nipple torture
|
||||
non-consensual
|
||||
ntr
|
||||
orgasm denial
|
||||
parasite
|
||||
piercing
|
||||
prolapse
|
||||
prostitution
|
||||
public use
|
||||
puke
|
||||
puppy play
|
||||
rape
|
||||
ryona
|
||||
scar
|
||||
scat
|
||||
shemale
|
||||
shota
|
||||
shotacon
|
||||
sister
|
||||
slave
|
||||
slavery
|
||||
snuff
|
||||
tentacles
|
||||
toddlercon
|
||||
torture
|
||||
trans
|
||||
transgender
|
||||
trap
|
||||
traps
|
||||
guro
|
||||
furry
|
||||
loli
|
||||
incest
|
||||
tentacles
|
||||
shemale
|
||||
scat
|
||||
яой
|
||||
юри
|
||||
трап
|
||||
копро
|
||||
unbirth
|
||||
urination
|
||||
vaginal birth
|
||||
violent
|
||||
vomit
|
||||
vore
|
||||
watersports
|
||||
yaoi
|
||||
yuri
|
||||
гуро
|
||||
тентакли
|
||||
футанари
|
||||
инцест
|
||||
boys' love
|
||||
girls' love
|
||||
bdsm
|
||||
копро
|
||||
тентакли
|
||||
трап
|
||||
футанари
|
||||
юри
|
||||
яой
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
unqualifiedResLocale=en-US
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="favourites">পছন্দের গুলো</string>
|
||||
<string name="history">ইতিহাস</string>
|
||||
<string name="favourites">পছন্দের</string>
|
||||
<string name="history">সম্প্রতি দেখা</string>
|
||||
<string name="local_storage">লোকাল স্টোরেজ</string>
|
||||
<string name="_continue">চালিয়ে যান</string>
|
||||
<string name="clear_thumbs_cache">থাম্বনেইল ক্যাচ সাফ করুন</string>
|
||||
@@ -12,20 +12,20 @@
|
||||
<string name="app_update_available">অ্যাপের নতুন ভার্সন পাওয়া গেছে</string>
|
||||
<string name="open_in_browser">ব্রাউজারে খুলুন</string>
|
||||
<string name="error_occurred">কিছু একটা সমস্যা হয়েছে</string>
|
||||
<string name="details">খুঁটিনাটি</string>
|
||||
<string name="chapters">অধ্যায়</string>
|
||||
<string name="details">বিস্তারিত</string>
|
||||
<string name="chapters">চ্যাপ্টার</string>
|
||||
<string name="list">তালিকা</string>
|
||||
<string name="detailed_list">পুঙ্খানুপুঙ্খ তালিকা</string>
|
||||
<string name="detailed_list">বিস্তারিত তালিকা</string>
|
||||
<string name="grid">গ্রিড</string>
|
||||
<string name="settings">সেটিং সমূহ</string>
|
||||
<string name="settings">সেটিংস</string>
|
||||
<string name="loading_">লোড হচ্ছে…</string>
|
||||
<string name="close">বন্ধ</string>
|
||||
<string name="try_again">আবারো চেষ্টা করুন</string>
|
||||
<string name="try_again">আবার চেষ্টা করুন</string>
|
||||
<string name="clear_history">ইতিহাস মুছুন</string>
|
||||
<string name="computing_">প্রস্তুত হচ্ছে…</string>
|
||||
<string name="chapter_d_of_d">%2$d টির মধ্যে %1$d তম পর্ব</string>
|
||||
<string name="chapter_d_of_d">%2$d এর্ %1$d তম অধ্যায়</string>
|
||||
<string name="nothing_found">কিছু পাওয়া যায়নি</string>
|
||||
<string name="history_is_empty">কোনো ইতিহাস লেখা হয়নি</string>
|
||||
<string name="history_is_empty">কোনো ইতিহাস নেই</string>
|
||||
<string name="read">পড়ুন</string>
|
||||
<string name="add_to_favourites">পছন্দ করুন</string>
|
||||
<string name="text_file_not_supported">একটি ZIP অথবা CBZ ফাইল নিন</string>
|
||||
@@ -38,7 +38,7 @@
|
||||
<string name="operation_not_supported">এই কাজটি করা সম্ভব নয়</string>
|
||||
<string name="switch_pages">পেজ পাল্টান</string>
|
||||
<string name="search_history_cleared">সাফ করা হয়েছে</string>
|
||||
<string name="network_error">নেটওয়ার্কে সমস্যা</string>
|
||||
<string name="network_error">নেটওয়ার্কে ত্রুটি</string>
|
||||
<string name="remote_sources">মানগা সোর্স সমূহ</string>
|
||||
<string name="you_have_not_favourites_yet">এখনো কিছু পছন্দ হয়নি</string>
|
||||
<string name="add_new_category">নতুন বিভাগ</string>
|
||||
@@ -176,4 +176,7 @@
|
||||
<string name="theme_name_dynamic">ডাইনামিক</string>
|
||||
<string name="theme_name_miku">মিকু</string>
|
||||
<string name="data_not_restored">ডেটা পুনরুদ্ধার করা হয়নি</string>
|
||||
<string name="incognito_mode_hint">আপনার পড়ার অগ্রগতি সেভ হবে না</string>
|
||||
<string name="volume_">আওয়াজ%d</string>
|
||||
<string name="volume_unknown">অজানা ভলিউম</string>
|
||||
</resources>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<string name="history_is_empty">Zatím žádná historie</string>
|
||||
<string name="read">Číst</string>
|
||||
<string name="you_have_not_favourites_yet">Zatím žádné oblíbené</string>
|
||||
<string name="add_to_favourites">Oblíbit toto</string>
|
||||
<string name="add_to_favourites">Přidat do oblíbených</string>
|
||||
<string name="add">Přidat</string>
|
||||
<string name="share">Sdílet</string>
|
||||
<string name="create_shortcut">Vytvořit zkratku…</string>
|
||||
@@ -791,4 +791,15 @@
|
||||
<string name="error_disclaimer_app_outdated">Vypadá to, že vaše verze Kotatsu je zastaralá. Prosíme, nainstalujte nejnovější verzi pro získání všech dostupných oprav chyb.</string>
|
||||
<string name="disable_captcha_notifications">Vypnout oznámení o captcha</string>
|
||||
<string name="disable_captcha_notifications_summary">Nebudete dostávat oznámení o řešení CAPTCHA pro tento zdroj, ale to může vést k rozbití operací na pozadí (hledání nových kapitol, získávání doporučení atd)</string>
|
||||
<string name="tags_warnings">Zvýraznit nebezpečné žánry</string>
|
||||
<string name="tags_warnings_summary">Zvýraznit žánry, které mohou být nevhodné pro většinu uživatelů</string>
|
||||
<string name="nsfw_16">16+</string>
|
||||
<string name="link_to_manga_in_app">Odkaz na mangau v Kotatsu</string>
|
||||
<string name="clear_browser_data">Vyčistit data prohlížeče</string>
|
||||
<string name="exclude_nsfw_from_suggestions_summary">Dospělá manga nebude zobrazena v návrzích. Tato funkce může být nepřesná s některými zdroji</string>
|
||||
<string name="include_disabled_sources">Zahrnout vypnuté zdroje</string>
|
||||
<string name="suggestions_disabled_sources_summary">Zobrazit návrhy ze všech zdrojů mangy, včetně vypnutých</string>
|
||||
<string name="clear_browser_data_summary">Vyčistit data prohlížeče, např. cache a cookies. Upozornění: Všude budete odhlášeni a budte muset znovu řešit captcha</string>
|
||||
<string name="global_search">Globální vyhledávání</string>
|
||||
<string name="search_everywhere">Hledat všude</string>
|
||||
</resources>
|
||||
|
||||
@@ -671,7 +671,7 @@
|
||||
<string name="seconds_short">s %d</string>
|
||||
<string name="minutes_seconds_short">%1$d m %2$d s</string>
|
||||
<string name="sfw">SFW</string>
|
||||
<string name="not_in_favorites">Wala sa paborito mo</string>
|
||||
<string name="not_in_favorites">Wala sa mga paborito</string>
|
||||
<string name="unpopular">Hindi sikat</string>
|
||||
<string name="low_rating">Mababa ang rating</string>
|
||||
<string name="sort_order_asc">Pataas</string>
|
||||
@@ -793,4 +793,16 @@
|
||||
<string name="unnamed_chapter">Walang pangalan na kabanata</string>
|
||||
<string name="error_disclaimer_app_outdated">Mukhang luma na ang bersyon mo ng Kotatsu. Mangyaring i-install ang pinakabagong bersyon upang makuha ang lahat ng magagamit na mga pag-aayos.</string>
|
||||
<string name="error_disclaimer_report">Maaari kang magsumite ng ulat ng bug sa mga developer. Makakatulong ito sa amin na magsiyasat at ayusin ang isyu.</string>
|
||||
<string name="tags_warnings">I-highlight ang mga mapanganib na genre</string>
|
||||
<string name="nsfw_16">16+</string>
|
||||
<string name="clear_browser_data">Linisin ang data ng browser</string>
|
||||
<string name="no_write_permission_to_file">Walang pahintulot na magsulat ng file</string>
|
||||
<string name="exclude_nsfw_from_suggestions_summary">Ang manga pang-matanda ay hindi ipapakita sa mga mungkahi. Maaaring gumana nang hindi tumpak ang opsyong ito sa ilang source</string>
|
||||
<string name="include_disabled_sources">Isama ang mga hindi pinagana na source</string>
|
||||
<string name="suggestions_disabled_sources_summary">Magpakita ng mga mungkahi mula sa lahat ng manga source, kabilang ang mga hindi napagana</string>
|
||||
<string name="tags_warnings_summary">I-highlight ang mga genre na maaaring hindi naaangkop para sa karamihan ng mga user</string>
|
||||
<string name="link_to_manga_in_app">Link sa manga sa Kotatsu</string>
|
||||
<string name="simple">Pinasimple</string>
|
||||
<string name="link_to_manga_on_s">Link sa manga sa %s</string>
|
||||
<string name="clear_browser_data_summary">Linisin ang data ng browser tulad ng cache at mga cookie. Babala: Ang awtorisasyon sa mga source ng manga ay maaaring maging di-balido</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="local_storage">Penyimpanan lokal</string>
|
||||
<string name="favourites">Favorit</string>
|
||||
<string name="favourites">Banyak disukai</string>
|
||||
<string name="history">Riwayat</string>
|
||||
<string name="error_occurred">Terjadi kesalahan</string>
|
||||
<string name="network_error">Kesalahan jaringan</string>
|
||||
@@ -18,7 +18,7 @@
|
||||
<string name="nothing_found">Tidak ditemukan</string>
|
||||
<string name="history_is_empty">Riwayat kosong</string>
|
||||
<string name="read">Baca</string>
|
||||
<string name="you_have_not_favourites_yet">Belum ada favorit</string>
|
||||
<string name="you_have_not_favourites_yet">Belum ada yang disukai</string>
|
||||
<string name="add_to_favourites">Buat favorit</string>
|
||||
<string name="add_new_category">Kategori baru</string>
|
||||
<string name="add">Tambah</string>
|
||||
@@ -66,7 +66,7 @@
|
||||
<string name="reader_settings">Pengaturan pembaca</string>
|
||||
<string name="switch_pages">Ganti halaman</string>
|
||||
<string name="chapters">Bab</string>
|
||||
<string name="list">Daftari</string>
|
||||
<string name="list">Daftar</string>
|
||||
<string name="detailed_list">Daftar rinci</string>
|
||||
<string name="webtoon">Webtoon</string>
|
||||
<string name="read_mode">Mode baca</string>
|
||||
@@ -204,7 +204,7 @@
|
||||
<string name="create_category">Kategori baru</string>
|
||||
<string name="light_indicator">Indikator LED</string>
|
||||
<string name="vibration">Getaran</string>
|
||||
<string name="favourites_categories">Kategori favorit</string>
|
||||
<string name="favourites_categories">Kategori disukai</string>
|
||||
<string name="remove_category">Hapus</string>
|
||||
<string name="clear_updates_feed">Bersihkan aliran pembaruan</string>
|
||||
<string name="right_to_left">Kanan-ke-kiri</string>
|
||||
@@ -803,4 +803,6 @@
|
||||
<string name="global_search">"Pencarian global"</string>
|
||||
<string name="search_everywhere">Cari dimana saja</string>
|
||||
<string name="badges_in_lists">Lencana dalam daftar</string>
|
||||
<string name="tags_warnings">tandai genre berbahaya</string>
|
||||
<string name="tags_warnings_summary">Tandai genre yang mungkin tidak pantas untuk sebagian besar pengguna</string>
|
||||
</resources>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<string name="search_manga">Cerca manga</string>
|
||||
<string name="search">Cerca</string>
|
||||
<string name="share_s">Condividi %s</string>
|
||||
<string name="create_shortcut">Crea una scorciatoia…</string>
|
||||
<string name="create_shortcut">Crea scorciatoia</string>
|
||||
<string name="share">Condividi</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="add">Aggiungi</string>
|
||||
@@ -249,7 +249,7 @@
|
||||
<string name="send">Invia</string>
|
||||
<string name="disable_all">Disabilita tutto</string>
|
||||
<string name="use_fingerprint">Usa le impronte digitali se disponibili</string>
|
||||
<string name="appwidget_shelf_description">Manga dai preferiti</string>
|
||||
<string name="appwidget_shelf_description">Manga dai tuoi preferiti</string>
|
||||
<string name="appwidget_recent_description">I manga letti di recente</string>
|
||||
<string name="report">Segnala</string>
|
||||
<string name="tracking">Monitoraggio</string>
|
||||
@@ -669,7 +669,7 @@
|
||||
<string name="too_many_requests_message_retry">Troppe richieste. Riprova dopo %s</string>
|
||||
<string name="skip_all">Salta tutto</string>
|
||||
<string name="stuck">Bloccato</string>
|
||||
<string name="not_in_favorites">Non nei preferiti</string>
|
||||
<string name="not_in_favorites">Non presente nei preferiti</string>
|
||||
<string name="plugin_incompatible">Plugin incompatibile o errore interno. Assicurati di utilizzare l\'ultima versione del plugin e di Kotatsu</string>
|
||||
<string name="updated_long_ago">Aggiornato molto tempo fa</string>
|
||||
<string name="unpopular">Impopolare</string>
|
||||
@@ -804,4 +804,6 @@
|
||||
<string name="include_disabled_sources">Includi fonti disabilitate</string>
|
||||
<string name="suggestions_disabled_sources_summary">Mostra suggerimenti da tutte le fonti manga, incluse quelle disabilite</string>
|
||||
<string name="exclude_nsfw_from_suggestions_summary">I manga per adulti non verranno mostrati nei suggerimenti. Questa opzione potrebbe non funzionare accuratamente con alcune fonti</string>
|
||||
<string name="tags_warnings">Evidenzia i generi pericolosi</string>
|
||||
<string name="tags_warnings_summary">Evidenzia i generi che potrebbero essere inappropriati per la maggior parte degli utenti</string>
|
||||
</resources>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<string name="cannot_find_available_storage">Sem espaço de armazenamento disponível</string>
|
||||
<string name="other_storage">Outro armazenamento</string>
|
||||
<string name="done">Feito</string>
|
||||
<string name="all_favourites">Todas as favoritas</string>
|
||||
<string name="all_favourites">Todos os favoritos</string>
|
||||
<string name="favourites_category_empty">Categoria vazia</string>
|
||||
<string name="read_later">Ler depois</string>
|
||||
<string name="updates">Atualizações</string>
|
||||
@@ -158,7 +158,7 @@
|
||||
<string name="show_pages_numbers">Páginas numeradas</string>
|
||||
<string name="screenshots_policy">Política de capturas de tela</string>
|
||||
<string name="screenshots_allow">Permitir</string>
|
||||
<string name="screenshots_block_nsfw">Bloquear no NSFW</string>
|
||||
<string name="screenshots_block_nsfw">Bloquear conteúdo NSFW</string>
|
||||
<string name="screenshots_block_all">Nunca permitir</string>
|
||||
<string name="suggestions">Sugestões</string>
|
||||
<string name="suggestions_enable">Ativar sugestões</string>
|
||||
@@ -269,7 +269,7 @@
|
||||
<string name="clear_cookies_summary">Pode ajudar em caso de problemas. Todas as autorizações serão invalidadas</string>
|
||||
<string name="show_reading_indicators">Mostrar indicadores de progresso de leitura</string>
|
||||
<string name="data_deletion">Exclusão de dados</string>
|
||||
<string name="show_reading_indicators_summary">Mostrar porcentagem lida no histórico e em favoritas</string>
|
||||
<string name="show_reading_indicators_summary">Mostrar porcentagem de leitura no histórico e nos favoritos</string>
|
||||
<string name="exclude_nsfw_from_history_summary">Obras marcadas como NSFW nunca serão adicionadas ao histórico e seu progresso não será salvo</string>
|
||||
<string name="show_all">Mostrar tudo</string>
|
||||
<string name="clear_all_history">Limpar todo o histórico</string>
|
||||
@@ -300,7 +300,7 @@
|
||||
<string name="other_cache">Outro cache</string>
|
||||
<string name="storage_usage">Uso do armazenamento</string>
|
||||
<string name="available">Disponível</string>
|
||||
<string name="removed_from_favourites">Removida das favoritas</string>
|
||||
<string name="removed_from_favourites">Removido dos favoritos</string>
|
||||
<string name="options">Opções</string>
|
||||
<string name="incognito_mode">Modo anônimo</string>
|
||||
<string name="automatic_scroll">Rolagem automática</string>
|
||||
@@ -590,7 +590,7 @@
|
||||
<string name="chapters_grid_view">Exibição em grade</string>
|
||||
<string name="alternatives">Alternativas</string>
|
||||
<string name="manga_migration">Migração de obra</string>
|
||||
<string name="migration_completed">Migração completada</string>
|
||||
<string name="migration_completed">Migração concluída</string>
|
||||
<string name="chapters_deleted_pattern">%1$s removido, %2$s limpo</string>
|
||||
<string name="delete_read_chapters">Apagar capítulos lidos</string>
|
||||
<string name="delete_read_chapters_summary">Apagar capítulos lidos do armazenamento local para liberar espaço</string>
|
||||
@@ -617,8 +617,8 @@
|
||||
<string name="show_updated">Mostrar atualização</string>
|
||||
<string name="disable_connectivity_check">Desativar a verificação de conectividade</string>
|
||||
<string name="disable_connectivity_check_summary">Ignore a verificação de conectividade caso tenha problemas com ela (por exemplo, entrar no modo off-line mesmo que a rede esteja conectada)</string>
|
||||
<string name="webtoon_gaps">Lacunas no modo webtoon</string>
|
||||
<string name="webtoon_gaps_summary">Mostrar lacunas verticais entre as páginas no modo webtoon</string>
|
||||
<string name="webtoon_gaps">Lacunas no modo Webtoon</string>
|
||||
<string name="webtoon_gaps_summary">Mostrar lacunas verticais entre as páginas no modo Webtoon</string>
|
||||
<string name="authors">Autores</string>
|
||||
<string name="ignore_ssl_errors_summary">Você pode desativar a verificação de certificados SSL caso tenha problemas relacionados a SSL ao acessar recursos de rede. Isso pode afetar sua segurança. É necessário reiniciar o aplicativo após alterar essa configuração.</string>
|
||||
<string name="search_suggestions">Sugestões de pesquisa</string>
|
||||
@@ -633,7 +633,7 @@
|
||||
<string name="new_chapters_pattern">%1$s: %2$d</string>
|
||||
<string name="pin_navigation_ui">Fixar interface de navegação</string>
|
||||
<string name="pin_navigation_ui_summary">Não esconder barra de navegação e visualização de pesquisa ao rolar</string>
|
||||
<string name="_new">Novo</string>
|
||||
<string name="_new">Novos</string>
|
||||
<string name="all_languages">Todas os idiomas</string>
|
||||
<string name="screenshots_block_incognito">Bloquear no modo de navegação anônima</string>
|
||||
<string name="image_server">Servidor de imagem preferido</string>
|
||||
@@ -711,7 +711,7 @@
|
||||
<string name="minutes_seconds_short">%1$d min %2$d s</string>
|
||||
<string name="unpopular">Impopular</string>
|
||||
<string name="stuck">Preso</string>
|
||||
<string name="not_in_favorites">Não está nas favoritas</string>
|
||||
<string name="not_in_favorites">Não está nos favoritos</string>
|
||||
<string name="fixing_manga">Corrigindo a obra</string>
|
||||
<string name="fixed">Corrigida</string>
|
||||
<string name="no_fix_required">Nenhuma correção necessária para \"%s\"</string>
|
||||
@@ -768,7 +768,7 @@
|
||||
<string name="rating">Avaliação</string>
|
||||
<string name="source">Fonte</string>
|
||||
<string name="restoring_backup">Restaurando backup</string>
|
||||
<string name="error_disclaimer_manga">Tente abrir o manga em um navegador para ter certeza de que o mesmo está disponível na fonte</string>
|
||||
<string name="error_disclaimer_manga">Tente abrir o manga em um navegador para ter certeza de que o mesmo está disponível na fonte.</string>
|
||||
<string name="error_disclaimer_app_outdated">Parece que a sua versão do Kotatsu está desatualizada. Por favor instale a última versão para obter todos as correções disponíveis.</string>
|
||||
<string name="search_everywhere">Pesquise em todos os lugares</string>
|
||||
<string name="clear_browser_data">Limpar dados do navegador</string>
|
||||
@@ -797,4 +797,10 @@
|
||||
<string name="disable_captcha_notifications_summary">Você não receberá notificações sobre solucionar CAPTCHA para essa fonte, mas isso pode causar falha em operações de segundo plano (checagem de novos capítulos, obtenção de recomendações, etc)</string>
|
||||
<string name="global_search">Pesquisa global</string>
|
||||
<string name="badges_in_lists">Emblemas em listas</string>
|
||||
<string name="tags_warnings">Destacar gêneros perigosos</string>
|
||||
<string name="tags_warnings_summary">Destacar gêneros que podem ser inapropriados para a maioria dos usuários</string>
|
||||
<string name="nsfw_16">+16</string>
|
||||
<string name="exclude_nsfw_from_suggestions_summary">Mangás adultos não serão exibidos nas sugestões. Essa opção pode funcionar de forma imprecisa com algumas fontes</string>
|
||||
<string name="include_disabled_sources">Incluir fontes desabilitadas</string>
|
||||
<string name="suggestions_disabled_sources_summary">Mostrar sugestões de todas as fontes de mangá, incluindo as desabilitadas</string>
|
||||
</resources>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<string name="add">Добавить</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="share">Поделиться</string>
|
||||
<string name="create_shortcut">Создать ярлык…</string>
|
||||
<string name="create_shortcut">Создать ярлык</string>
|
||||
<string name="share_s">Поделиться %s</string>
|
||||
<string name="search">Поиск</string>
|
||||
<string name="search_manga">Поиск манги</string>
|
||||
@@ -795,4 +795,15 @@
|
||||
<string name="error_disclaimer_manga">Попробуйте открыть мангу в браузере, чтобы убедиться, что она доступна в источнике.</string>
|
||||
<string name="disable_captcha_notifications">Отключить уведомления о CAPTCHA</string>
|
||||
<string name="disable_captcha_notifications_summary">Вы не будете получать уведомления о прохождении CAPTCHA для этого источника, но это может привести к тому, что фоновые операции перестанут работать (проверка новых глав, обновление рекомендаций и т. д.)</string>
|
||||
<string name="tags_warnings">Выделять опасные жанры</string>
|
||||
<string name="tags_warnings_summary">Выделять жанры, которые могут быть неприемлемы для большинства пользователей</string>
|
||||
<string name="clear_browser_data">Очистить данные браузера</string>
|
||||
<string name="no_write_permission_to_file">Нет прав на запись в файл</string>
|
||||
<string name="link_to_manga_on_s">Ссылка на мангу на %s</string>
|
||||
<string name="exclude_nsfw_from_suggestions_summary">Взрослая манга не будет отображаться в рекомендациях. Эта опция может не работать с некоторыми источниками</string>
|
||||
<string name="include_disabled_sources">Включить отключенные источники</string>
|
||||
<string name="suggestions_disabled_sources_summary">Отображать рекомендации из всех источников манги, включая отключенные</string>
|
||||
<string name="link_to_manga_in_app">Ссылка на мангу в Kotatsu</string>
|
||||
<string name="nsfw_16">16+</string>
|
||||
<string name="clear_browser_data_summary">Удалить данные встроенного браузера, такие как кэш и куки. Внимание: авторизация в источниках манги может быть потеряна</string>
|
||||
</resources>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<string name="nothing_found">Hiçbir şey bulunamadı</string>
|
||||
<string name="history_is_empty">Geçmiş yok</string>
|
||||
<string name="read">Oku</string>
|
||||
<string name="you_have_not_favourites_yet">Henüz favorileriniz yok</string>
|
||||
<string name="you_have_not_favourites_yet">Henüz favoriniz yok</string>
|
||||
<string name="add_to_favourites">Favorilere ekle</string>
|
||||
<string name="add_new_category">Yeni kategori</string>
|
||||
<string name="add">Ekle</string>
|
||||
@@ -74,7 +74,7 @@
|
||||
<string name="vibration">Titreşim</string>
|
||||
<string name="other_storage">Diğer depolama</string>
|
||||
<string name="updates">Güncellemeler</string>
|
||||
<string name="create_shortcut">Kısayol oluştur…</string>
|
||||
<string name="create_shortcut">Kısayol oluştur</string>
|
||||
<string name="_import">İçe aktar</string>
|
||||
<string name="delete_manga">Mangayı sil</string>
|
||||
<string name="computing_">Bilgi işleniyor…</string>
|
||||
@@ -804,4 +804,6 @@
|
||||
<string name="exclude_nsfw_from_suggestions_summary">Yetişkin mangaları önerilerde gösterilmeyecektir. Bu seçenek her kaynak için doğru çalışmayabilir</string>
|
||||
<string name="nsfw_16">16+</string>
|
||||
<string name="include_disabled_sources">Devre dışı bırakılmış kaynakları dahil et</string>
|
||||
<string name="tags_warnings">Riskli türleri işaretle</string>
|
||||
<string name="tags_warnings_summary">Çoğu kullanıcılar için uygunsuz olabilecek türleri işaretle</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user