Compare commits

...

50 Commits
v8.1 ... v8.1.7

Author SHA1 Message Date
Koitharu
ab2235d0ca Update parsers 2025-05-18 14:40:45 +03:00
Koitharu
cbf707b403 Fix locales config
(cherry picked from commit 61c068d4ee)
2025-05-18 14:36:09 +03:00
Koitharu
8971c7a6a2 Fix color filte activity strings
(cherry picked from commit 8f8abcc3f6)
2025-05-18 14:35:19 +03:00
Koitharu
1576c9cdde Fix search menu item duplication
(cherry picked from commit a4b9acd622)
2025-05-18 14:34:44 +03:00
Koitharu
beba4f029a Fix per-app locale selection 2025-05-08 20:46:58 +03:00
Koitharu
7cf7a62881 Update details info card color
(cherry picked from commit 8d325aea0a)
2025-05-08 19:36:16 +03:00
kadirkid
c1e84715fb Switch per language support to manual
The current automatic support setup has a bug where the app language will change for users with Android 15 when there is a configuration change like rotating a screen. It seems that that using generateLocaleConfig on AGP 8.8+ triggers a bug in Android 15 (android:defaultLocale) which causes this issue

(cherry picked from commit 104d8da655)
2025-05-08 19:36:08 +03:00
Koitharu
a3cc5726ee Update parsers 2025-05-08 19:35:45 +03:00
Koitharu
3023c02f12 Update parsers 2025-05-03 08:37:46 +03:00
Koitharu
efff034dc6 Remove duplicated warnlist tags 2025-05-03 08:33:36 +03:00
Draken
2bb5673446 Update tags_warnlist
(cherry picked from commit 8d78b19128)
2025-05-03 08:31:39 +03:00
Koitharu
0983885fa2 Update private notifications visibility 2025-05-03 08:30:36 +03:00
Koitharu
4449996a91 Fix search suggestions
(cherry picked from commit 1a8045b89f)
2025-05-02 14:44:39 +03:00
Koitharu
9cf496b7c4 AVIF images downsampling
(cherry picked from commit 5d890cb3d0)
2025-05-02 14:44:25 +03:00
Koitharu
4fb1db47ab Fix image loading
(cherry picked from commit 257f583f78)
2025-05-02 14:44:12 +03:00
Koitharu
14b89fbee2 Use pagination for bookmarks backup 2025-04-19 08:31:47 +03:00
Koitharu
8291c55fc9 Fix some database-related crashes 2025-04-19 08:16:08 +03:00
Koitharu
46ddcb7518 Update page loading ui 2025-04-13 11:33:04 +03:00
Koitharu
cf2d1aa6fb Fix main navbar height 2025-04-13 11:21:29 +03:00
Koitharu
ab3dd8aacb Merge pull request #1374 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-13 10:56:48 +03:00
srntskl-111
ae868fa9d1 Translated using Weblate (Indonesian)
Currently translated at 98.8% (805 of 814 strings)

Co-authored-by: srntskl-111 <maskhraish@outlook.co.id>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-04-13 09:55:44 +02:00
Frosted
4ecbf5978e Translated using Weblate (Turkish)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-04-13 09:55:43 +02:00
Koitharu
31586cf48f Fix avif image decoding 2025-04-13 10:47:37 +03:00
Koitharu
3725a6e58f Update page loading ui 2025-04-13 10:01:24 +03:00
Koitharu
313c2ab2bf Respect rounded corners for page numbers (#1360) 2025-04-08 09:09:36 +03:00
Koitharu
fe5d37f45e Fix hiding page loading indicator (close #1357) 2025-04-08 09:09:36 +03:00
Koitharu
92f6221ba0 Merge pull request #1367 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-08 09:03:34 +03:00
大王叫我来巡山
0590a0c56f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (811 of 814 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-04-07 18:01:57 +02:00
Koitharu
13ffc3a515 Translated using Weblate (Russian)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-04-07 18:01:55 +02:00
Nicola Bortoletto
74b36226f2 Translated using Weblate (Italian)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-04-07 18:01:53 +02:00
Boqirz
d501d0304a Translated using Weblate (Indonesian)
Currently translated at 98.7% (804 of 814 strings)

Co-authored-by: Boqirz <alveromodar@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-04-07 18:01:51 +02:00
Draken
1059933c87 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (814 of 814 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-04-07 18:01:49 +02:00
Koitharu
5fa58b931e Fix Cloudflare protection resolving 2025-04-06 18:10:34 +03:00
Koitharu
ddecc72de7 Update page state management in reader 2025-04-06 16:26:13 +03:00
Koitharu
d35a0c5e1e Allow to open reader when details is not loaded yet 2025-04-03 19:41:44 +03:00
Koitharu
340994ce77 Fix reader slider behavior 2025-04-03 13:59:26 +03:00
Koitharu
42b2f21c4d Fix bottom navigation insets #1341 2025-04-03 13:19:10 +03:00
Koitharu
e4b9da54dd Update parsers 2025-04-03 12:22:51 +03:00
Koitharu
ccc41314ae UI fixes 2025-04-03 12:21:06 +03:00
Koitharu
93eb6a19a5 Update page loading ui 2025-04-03 12:21:06 +03:00
Koitharu
e4f2e19d2c Merge pull request #1358 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-03 12:19:44 +03:00
Thinker
73a687c9a7 Translated using Weblate (Bengali)
Currently translated at 21.8% (178 of 814 strings)

Co-authored-by: Thinker <sayakkundu711@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2025-04-03 03:49:15 +02:00
Draken
32ca3c11fa Translated using Weblate (Vietnamese)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Deivinni Silva
0d648dd188 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Frosted
86b7989c89 Translated using Weblate (Turkish)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Alvoracz
01be6ab596 Translated using Weblate (Czech)
Currently translated at 99.3% (809 of 814 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Infy's Tagalog Translations
a3d01e8d34 Translated using Weblate (Filipino)
Currently translated at 99.7% (812 of 814 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
gekka
808bd47b64 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (811 of 814 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-04-03 03:49:13 +02:00
Nicola Bortoletto
f4b506b26b Translated using Weblate (Italian)
Currently translated at 99.8% (813 of 814 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-04-03 03:49:13 +02:00
Koitharu
1f0d2e2039 Fix crash when open non-http url in browser 2025-03-31 10:19:31 +03:00
105 changed files with 1421 additions and 947 deletions

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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(),
)

View File

@@ -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?>

View File

@@ -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,

View File

@@ -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()

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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\"")

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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(

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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") {

View File

@@ -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,
)

View File

@@ -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 ->

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 }

View File

@@ -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
}
}
}

View File

@@ -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) {

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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(),

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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(),

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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,
)

View File

@@ -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) {

View File

@@ -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,
)

View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 ->

View File

@@ -23,5 +23,6 @@ class SearchSuggestionAdapter(
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
.addDelegate(searchSuggestionQueryHintAD(listener))
.addDelegate(searchSuggestionAuthorAD(listener))
.addDelegate(searchSuggestionTextAD())
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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>()

View File

@@ -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 {

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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() }
}

View File

@@ -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 {

View File

@@ -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),
)

View File

@@ -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(

View 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="?android:colorBackground" />
</selector>

View 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>

View 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>

View File

@@ -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"

View File

@@ -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"

View 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" />

View 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

View File

@@ -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"

View File

@@ -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
копро
тентакли
трап
футанари
юри
яой

View File

@@ -1 +0,0 @@
unqualifiedResLocale=en-US

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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