Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2235d0ca | ||
|
|
cbf707b403 | ||
|
|
8971c7a6a2 | ||
|
|
1576c9cdde | ||
|
|
beba4f029a | ||
|
|
7cf7a62881 | ||
|
|
c1e84715fb | ||
|
|
a3cc5726ee | ||
|
|
3023c02f12 | ||
|
|
efff034dc6 | ||
|
|
2bb5673446 | ||
|
|
0983885fa2 | ||
|
|
4449996a91 | ||
|
|
9cf496b7c4 | ||
|
|
4fb1db47ab | ||
|
|
14b89fbee2 | ||
|
|
8291c55fc9 | ||
|
|
46ddcb7518 | ||
|
|
cf2d1aa6fb | ||
|
|
ab3dd8aacb | ||
|
|
ae868fa9d1 | ||
|
|
4ecbf5978e | ||
|
|
31586cf48f | ||
|
|
3725a6e58f | ||
|
|
313c2ab2bf | ||
|
|
fe5d37f45e | ||
|
|
92f6221ba0 | ||
|
|
0590a0c56f | ||
|
|
13ffc3a515 | ||
|
|
74b36226f2 | ||
|
|
d501d0304a | ||
|
|
1059933c87 | ||
|
|
5fa58b931e | ||
|
|
ddecc72de7 | ||
|
|
d35a0c5e1e | ||
|
|
340994ce77 | ||
|
|
42b2f21c4d | ||
|
|
e4b9da54dd | ||
|
|
ccc41314ae | ||
|
|
93eb6a19a5 | ||
|
|
e4f2e19d2c | ||
|
|
73a687c9a7 | ||
|
|
32ca3c11fa | ||
|
|
0d648dd188 | ||
|
|
86b7989c89 | ||
|
|
01be6ab596 | ||
|
|
a3d01e8d34 | ||
|
|
808bd47b64 | ||
|
|
f4b506b26b | ||
|
|
1f0d2e2039 | ||
|
|
e3e315e2a6 | ||
|
|
bfc733784f | ||
|
|
3ff25de252 | ||
|
|
3c726c1c56 | ||
|
|
9cb7ff691f | ||
|
|
645ae3124f | ||
|
|
a3d1922913 | ||
|
|
62d2ea8f15 | ||
|
|
823752076b | ||
|
|
3cbd392c72 | ||
|
|
57f62f5860 | ||
|
|
648fab6be5 | ||
|
|
817ae68e67 | ||
|
|
7c4b91ddc4 | ||
|
|
d54e015195 | ||
|
|
e369d1ba9d | ||
|
|
1a4358998b | ||
|
|
c53a833d9d | ||
|
|
afff700ad3 | ||
|
|
5bc00bc7f5 | ||
|
|
e2ace90cdb | ||
|
|
1afbd2b6a8 | ||
|
|
d36c5af0c4 | ||
|
|
705bb2b084 | ||
|
|
a208d13930 | ||
|
|
44d8861b7f | ||
|
|
9821f06ca1 | ||
|
|
92f9f56f59 | ||
|
|
424c4d8827 | ||
|
|
24cf2a2725 | ||
|
|
1a5c3c1f6f | ||
|
|
0b8fbf892a | ||
|
|
a2f9356b8a | ||
|
|
7003463bac | ||
|
|
7a663fa9c1 | ||
|
|
9cc1cdac62 | ||
|
|
8d7f44d2da | ||
|
|
930d4dfd83 | ||
|
|
290cb652ee |
@@ -19,15 +19,16 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1004
|
||||
versionName = '8.0'
|
||||
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 {
|
||||
|
||||
@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
class KotatsuApp : BaseApp() {
|
||||
|
||||
@@ -67,7 +66,6 @@ class KotatsuApp : BaseApp() {
|
||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -24,6 +25,7 @@ class SettingsMenuProvider(
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
||||
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
@@ -44,6 +46,13 @@ class SettingsMenuProvider(
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_ssiv_debug -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
SubsamplingScaleImageView.isDebug = checked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_ssiv_debug"
|
||||
android:checkable="true"
|
||||
android:title="SSIV debug"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
<item
|
||||
android:id="@+id/action_leakcanary"
|
||||
android:checkable="true"
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
|
||||
@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AutoFixService : CoroutineIntentService() {
|
||||
@@ -95,7 +95,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
|
||||
@@ -17,9 +17,9 @@ abstract class BookmarksDao {
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||
|
||||
@@ -6,10 +6,18 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -18,6 +26,9 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
|
||||
@Inject
|
||||
lateinit var proxyProvider: ProxyProvider
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -28,8 +39,22 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
|
||||
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?,
|
||||
source: MangaSource,
|
||||
repository: ParserMangaRepository?
|
||||
)
|
||||
|
||||
override fun onApplyWindowInsets(
|
||||
v: View,
|
||||
insets: WindowInsetsCompat
|
||||
|
||||
@@ -8,31 +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 onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setDisplayHomeAsUp(true, true)
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
|
||||
@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.browser
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
|
||||
open class BrowserClient(
|
||||
private val proxyProvider: ProxyProvider,
|
||||
private val callback: BrowserCallback
|
||||
) : WebViewClientCompat() {
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -37,16 +39,14 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
|
||||
private lateinit var cfClient: CloudFlareClient
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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 {
|
||||
@@ -107,8 +107,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
setTitle(title)
|
||||
supportActionBar?.subtitle =
|
||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
|
||||
}
|
||||
|
||||
private fun restartCheck() {
|
||||
|
||||
@@ -4,17 +4,15 @@ import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
|
||||
private const val LOOP_COUNTER = 3
|
||||
|
||||
class CloudFlareClient(
|
||||
proxyProvider: ProxyProvider,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val callback: CloudFlareCallback,
|
||||
private val targetUrl: String,
|
||||
) : BrowserClient(proxyProvider, callback) {
|
||||
) : BrowserClient(callback) {
|
||||
|
||||
private val oldClearance = getClearance()
|
||||
private var counter = 0
|
||||
|
||||
@@ -102,6 +102,9 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
|
||||
@@ -28,7 +28,7 @@ class BackupRepository @Inject constructor(
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
||||
while (true) {
|
||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
||||
val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE)
|
||||
if (history.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class BackupRepository @Inject constructor(
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
||||
while (true) {
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE)
|
||||
if (favourites.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -78,19 +78,26 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun dumpBookmarks(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
||||
val all = db.getBookmarksDao().findAll()
|
||||
for ((m, b) in all) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
json.put("manga", manga)
|
||||
val tags = JSONArray()
|
||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
json.put("tags", tags)
|
||||
val bookmarks = JSONArray()
|
||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||
json.put("bookmarks", bookmarks)
|
||||
entry.data.put(json)
|
||||
while (true) {
|
||||
val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE)
|
||||
if (bookmarks.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += bookmarks.size
|
||||
for ((m, b) in bookmarks) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
json.put("manga", manga)
|
||||
val tags = JSONArray()
|
||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
json.put("tags", tags)
|
||||
val bookmarks = JSONArray()
|
||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||
json.put("bookmarks", bookmarks)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class NonFileUriException(
|
||||
val uri: Uri,
|
||||
) : IllegalArgumentException("Cannot resolve file name of \"$uri\"")
|
||||
@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
@@ -163,7 +164,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.maxBitmapSize
|
||||
import coil3.util.component1
|
||||
import coil3.util.component2
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
|
||||
|
||||
class AvifImageDecoder(
|
||||
private val source: ImageSource,
|
||||
@@ -20,27 +25,52 @@ class AvifImageDecoder(
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val bytes = source.source().use {
|
||||
it.inputStream().toByteBuffer()
|
||||
}
|
||||
val info = Info()
|
||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||
throw ImageDecodeException(
|
||||
null,
|
||||
"avif",
|
||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, "avif")
|
||||
}
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
val bytes = source.source().readByteBuffer()
|
||||
val decoder = AvifDecoder.create(bytes) ?: throw ImageDecodeException(
|
||||
uri = source.fileOrNull()?.toString(),
|
||||
format = "avif",
|
||||
message = "Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
try {
|
||||
val config = if (decoder.depth == 8 || decoder.alphaPresent) {
|
||||
Bitmap.Config.ARGB_8888
|
||||
} else {
|
||||
Bitmap.Config.RGB_565
|
||||
}
|
||||
val bitmap = createBitmap(decoder.width, decoder.height, config)
|
||||
val result = decoder.nextFrame(bitmap)
|
||||
if (result != 0) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(
|
||||
uri = source.fileOrNull()?.toString(),
|
||||
format = "avif",
|
||||
message = AvifDecoder.resultToString(result),
|
||||
)
|
||||
}
|
||||
// downscaling
|
||||
val (dstWidth, dstHeight) = DecodeUtils.computeDstSize(
|
||||
srcWidth = bitmap.width,
|
||||
srcHeight = bitmap.height,
|
||||
targetSize = options.size,
|
||||
scale = options.scale,
|
||||
maxSize = options.maxBitmapSize,
|
||||
)
|
||||
if (dstWidth < bitmap.width || dstHeight < bitmap.height) {
|
||||
val scaled = bitmap.scale(dstWidth, dstHeight)
|
||||
bitmap.recycle()
|
||||
DecodeResult(
|
||||
image = scaled.asImage(),
|
||||
isSampled = true,
|
||||
)
|
||||
} else {
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
decoder.release()
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.ext.MimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -24,7 +31,7 @@ object BitmapDecoderCompat {
|
||||
|
||||
@Blocking
|
||||
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
|
||||
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||
FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) }
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||
} else {
|
||||
@@ -51,6 +58,19 @@ object BitmapDecoderCompat {
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun createRegionDecoder(inoutStream: InputStream): BitmapRegionDecoder? = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(inoutStream)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(inoutStream, false)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun probeMimeType(file: File): MimeType? {
|
||||
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
|
||||
@@ -62,7 +82,7 @@ object BitmapDecoderCompat {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)?.recycle()
|
||||
return options.outMimeType?.toMimeTypeOrNull()
|
||||
options.outMimeType?.toMimeTypeOrNull()
|
||||
}.getOrNull()
|
||||
|
||||
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
||||
@@ -78,7 +98,7 @@ object BitmapDecoderCompat {
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||
val bitmap = createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, FORMAT_AVIF)
|
||||
|
||||
@@ -25,7 +25,7 @@ class CbzFetcher(
|
||||
val entryName = requireNotNull(uri.fragment)
|
||||
val fs = options.fileSystem.openZip(filePath)
|
||||
SourceFetchResult(
|
||||
source = ImageSource(entryName.toPath(), fs, closeable = fs),
|
||||
source = ImageSource(entryName.toPath(), fs),
|
||||
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import androidx.collection.ArrayMap
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.util.CoilUtils
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class CoilMemoryCacheKey(
|
||||
val data: MemoryCache.Key
|
||||
) : Parcelable {
|
||||
|
||||
companion object : Parceler<CoilMemoryCacheKey> {
|
||||
override fun CoilMemoryCacheKey.write(parcel: Parcel, flags: Int) = with(data) {
|
||||
parcel.writeString(key)
|
||||
parcel.writeInt(extras.size)
|
||||
for (entry in extras.entries) {
|
||||
parcel.writeString(entry.key)
|
||||
parcel.writeString(entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel): CoilMemoryCacheKey = CoilMemoryCacheKey(
|
||||
MemoryCache.Key(
|
||||
key = parcel.readString().orEmpty(),
|
||||
extras = run {
|
||||
val size = parcel.readInt()
|
||||
val map = ArrayMap<String, String>(size)
|
||||
repeat(size) {
|
||||
map.put(parcel.readString(), parcel.readString())
|
||||
}
|
||||
map
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
fun from(view: View): CoilMemoryCacheKey? {
|
||||
return (CoilUtils.result(view) as? SuccessResult)?.memoryCacheKey?.let {
|
||||
CoilMemoryCacheKey(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import coil3.Extras
|
||||
@@ -11,7 +10,6 @@ import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.getExtra
|
||||
import coil3.request.Options
|
||||
@@ -25,24 +23,37 @@ import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.isOriginal
|
||||
import coil3.size.pxOrElse
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.util.ext.copyWithNewSource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class RegionBitmapDecoder(
|
||||
private val source: ImageSource,
|
||||
private val fetchResult: SourceFetchResult,
|
||||
private val options: Options,
|
||||
private val imageLoader: ImageLoader,
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
|
||||
override suspend fun decode(): DecodeResult? {
|
||||
val regionDecoder = BitmapDecoderCompat.createRegionDecoder(fetchResult.source.source().inputStream())
|
||||
if (regionDecoder == null) {
|
||||
val revivedFetchResult = fetchResult.copyWithNewSource()
|
||||
return try {
|
||||
val fallbackDecoder = imageLoader.components.newDecoder(
|
||||
result = revivedFetchResult,
|
||||
options = options,
|
||||
imageLoader = imageLoader,
|
||||
startIndex = 0,
|
||||
)?.first
|
||||
if (fallbackDecoder == null || fallbackDecoder is RegionBitmapDecoder) {
|
||||
null
|
||||
} else {
|
||||
fallbackDecoder.decode()
|
||||
}
|
||||
} finally {
|
||||
revivedFetchResult.source.close()
|
||||
}
|
||||
}
|
||||
checkNotNull(regionDecoder)
|
||||
val bitmapOptions = BitmapFactory.Options()
|
||||
try {
|
||||
return try {
|
||||
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
|
||||
bitmapOptions.configureConfig()
|
||||
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
|
||||
@@ -149,7 +160,7 @@ class RegionBitmapDecoder(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Decoder = RegionBitmapDecoder(result.source, options)
|
||||
): Decoder = RegionBitmapDecoder(result, options, imageLoader)
|
||||
|
||||
override fun equals(other: Any?) = other is Factory
|
||||
|
||||
|
||||
@@ -149,6 +149,8 @@ fun Manga.chaptersCount(): Int {
|
||||
return max
|
||||
}
|
||||
|
||||
fun Manga.isNsfw(): Boolean = contentRating == ContentRating.ADULT || source.isNsfw()
|
||||
|
||||
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append(query)
|
||||
|
||||
@@ -18,11 +18,13 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||
import com.google.android.material.R as materialR
|
||||
import java.util.Locale
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
data object LocalMangaSource : MangaSource {
|
||||
override val name = "LOCAL"
|
||||
@@ -79,6 +81,8 @@ tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||
this
|
||||
}
|
||||
|
||||
fun MangaSource.getLocale(): Locale? = (unwrap() as? MangaParserSource)?.locale?.toLocaleOrNull()
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||
is MangaParserSource -> {
|
||||
val type = context.getString(source.contentType.titleResId)
|
||||
@@ -99,7 +103,7 @@ fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()
|
||||
}
|
||||
|
||||
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
||||
ForegroundColorSpan(context.getThemeColor(appcompatR.attr.colorError, Color.RED)),
|
||||
RelativeSizeSpan(0.74f),
|
||||
SuperscriptSpan(),
|
||||
) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.image.CoilMemoryCacheKey
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||
import org.koitharu.kotatsu.core.model.appUrl
|
||||
@@ -105,7 +106,7 @@ import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
|
||||
import java.io.File
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
class AppRouter private constructor(
|
||||
private val activity: FragmentActivity?,
|
||||
@@ -180,11 +181,12 @@ class AppRouter private constructor(
|
||||
)
|
||||
}
|
||||
|
||||
fun openImage(url: String, source: MangaSource?, anchor: View? = null) {
|
||||
fun openImage(url: String, source: MangaSource?, anchor: View? = null, preview: CoilMemoryCacheKey? = null) {
|
||||
startActivity(
|
||||
Intent(contextOrNull(), ImageActivity::class.java)
|
||||
.setData(url.toUri())
|
||||
.putExtra(KEY_SOURCE, source?.name),
|
||||
.putExtra(KEY_SOURCE, source?.name)
|
||||
.putExtra(KEY_PREVIEW, preview),
|
||||
anchor?.let { scaleUpActivityOptionsOf(it) },
|
||||
)
|
||||
}
|
||||
@@ -412,7 +414,7 @@ class AppRouter private constructor(
|
||||
return
|
||||
}
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setIcon(context.getThemeDrawable(materialR.attr.actionModeShareDrawable))
|
||||
setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable))
|
||||
setTitle(R.string.share)
|
||||
setItems(
|
||||
arrayOf(
|
||||
@@ -587,8 +589,11 @@ class AppRouter private constructor(
|
||||
/** Private utils **/
|
||||
|
||||
private fun startActivity(intent: Intent, options: Bundle? = null) {
|
||||
fragment?.startActivity(intent, options)
|
||||
?: activity?.startActivity(intent, options)
|
||||
fragment?.also {
|
||||
if (it.host != null) {
|
||||
it.startActivity(intent, options)
|
||||
}
|
||||
} ?: activity?.startActivity(intent, options)
|
||||
}
|
||||
|
||||
private fun startActivitySafe(intent: Intent): Boolean = try {
|
||||
@@ -768,6 +773,7 @@ class AppRouter private constructor(
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_MANGA_LIST = "manga_list"
|
||||
const val KEY_PAGES = "pages"
|
||||
const val KEY_PREVIEW = "preview"
|
||||
const val KEY_QUERY = "query"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
|
||||
@@ -105,6 +105,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) }
|
||||
|
||||
val isTagsWarningsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TAGS_WARNINGS, true)
|
||||
|
||||
var isNsfwContentDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
|
||||
@@ -359,6 +362,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isSuggestionsExcludeNsfw: Boolean
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
|
||||
|
||||
val isSuggestionsIncludeDisabledSources: Boolean
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS_DISABLED_SOURCES, false)
|
||||
|
||||
val isSuggestionsNotificationAvailable: Boolean
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false)
|
||||
|
||||
@@ -658,6 +664,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||
const val KEY_SUGGESTIONS_DISABLED_SOURCES = "suggestions_disabled_sources"
|
||||
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
|
||||
const val KEY_SHIKIMORI = "shikimori"
|
||||
const val KEY_ANILIST = "anilist"
|
||||
@@ -728,6 +735,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
|
||||
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
||||
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
|
||||
const val KEY_TAGS_WARNINGS = "tags_warnings"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
|
||||
@@ -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.abc_ic_clear_material)
|
||||
setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
import org.koitharu.kotatsu.core.util.ext.requireSerializable
|
||||
@@ -43,7 +44,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>(), Vie
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.buttonBrowser.setOnClickListener(this)
|
||||
binding.textViewSummary.text = exception.message
|
||||
val isUrlAvailable = !exception.getCauseUrl().isNullOrEmpty()
|
||||
val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true
|
||||
binding.buttonBrowser.isVisible = isUrlAvailable
|
||||
binding.textViewBrowser.isVisible = isUrlAvailable
|
||||
binding.textViewDescription.setTextAndVisible(
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
|
||||
import org.koitharu.kotatsu.databinding.FastScrollerBinding
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val SCROLLBAR_HIDE_DELAY = 1000L
|
||||
@@ -132,7 +133,7 @@ class FastScroller @JvmOverloads constructor(
|
||||
clipChildren = false
|
||||
orientation = HORIZONTAL
|
||||
|
||||
@ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY)
|
||||
@ColorInt var bubbleColor = context.getThemeColor(appcompatR.attr.colorControlNormal, Color.DKGRAY)
|
||||
@ColorInt var handleColor = bubbleColor
|
||||
@ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
|
||||
@ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.view.animation.DecelerateInterpolator
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
context: Context? = null,
|
||||
attrs: AttributeSet? = null,
|
||||
) : CoordinatorLayout.Behavior<BottomNavigationView>(context, attrs) {
|
||||
) : CoordinatorLayout.Behavior<NavigationBarView>(context, attrs) {
|
||||
|
||||
@ViewCompat.NestedScrollType
|
||||
private var lastStartedType: Int = 0
|
||||
@@ -34,13 +34,13 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
|
||||
override fun layoutDependsOn(parent: CoordinatorLayout, child: NavigationBarView, dependency: View): Boolean {
|
||||
return dependency is AppBarLayout
|
||||
}
|
||||
|
||||
override fun onDependentViewChanged(
|
||||
parent: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
dependency: View,
|
||||
): Boolean {
|
||||
val appBarSize = dependency.measureHeight()
|
||||
@@ -54,7 +54,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
|
||||
override fun onStartNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
directTargetChild: View,
|
||||
target: View,
|
||||
axes: Int,
|
||||
@@ -70,7 +70,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
|
||||
override fun onNestedPreScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
target: View,
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
@@ -85,7 +85,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
|
||||
override fun onStopNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: BottomNavigationView,
|
||||
child: NavigationBarView,
|
||||
target: View,
|
||||
type: Int,
|
||||
) {
|
||||
@@ -94,7 +94,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) {
|
||||
private fun animateBottomNavigationVisibility(child: NavigationBarView, isVisible: Boolean) {
|
||||
offsetAnimator?.cancel()
|
||||
offsetAnimator = ValueAnimator().apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
|
||||
@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.core.ui.widgets
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.TimeInterpolator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewPropertyAnimator
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
@@ -15,9 +17,11 @@ import androidx.core.view.isVisible
|
||||
import androidx.customview.view.AbsSavedState
|
||||
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationMenuView
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale
|
||||
import org.koitharu.kotatsu.core.util.ext.measureHeight
|
||||
import kotlin.math.max
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val STATE_DOWN = 1
|
||||
@@ -26,12 +30,14 @@ private const val STATE_UP = 2
|
||||
private const val SLIDE_UP_ANIMATION_DURATION = 225L
|
||||
private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
|
||||
|
||||
private const val MAX_ITEM_COUNT = 6
|
||||
|
||||
class SlidingBottomNavigationView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
|
||||
@StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
|
||||
) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes),
|
||||
) : NavigationBarView(context, attrs, defStyleAttr, defStyleRes),
|
||||
CoordinatorLayout.AttachedBehavior {
|
||||
|
||||
private var currentAnimator: ViewPropertyAnimator? = null
|
||||
@@ -55,6 +61,49 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
|
||||
return behavior
|
||||
}
|
||||
|
||||
/** From BottomNavigationView **/
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
super.onTouchEvent(event)
|
||||
// Consume all events to avoid views under the BottomNavigationView from receiving touch events.
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val minHeightSpec = makeMinHeightSpec(heightMeasureSpec)
|
||||
super.onMeasure(widthMeasureSpec, minHeightSpec)
|
||||
if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
|
||||
setMeasuredDimension(
|
||||
measuredWidth,
|
||||
max(
|
||||
measuredHeight,
|
||||
suggestedMinimumHeight + paddingTop + paddingBottom,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeMinHeightSpec(measureSpec: Int): Int {
|
||||
var minHeight = suggestedMinimumHeight
|
||||
if (MeasureSpec.getMode(measureSpec) != MeasureSpec.EXACTLY && minHeight > 0) {
|
||||
minHeight += paddingTop + paddingBottom
|
||||
|
||||
return MeasureSpec.makeMeasureSpec(
|
||||
max(MeasureSpec.getSize(measureSpec), minHeight), MeasureSpec.AT_MOST,
|
||||
)
|
||||
}
|
||||
|
||||
return measureSpec
|
||||
}
|
||||
|
||||
override fun getMaxItemCount(): Int = MAX_ITEM_COUNT
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun createNavigationBarMenuView(context: Context) = BottomNavigationMenuView(context)
|
||||
|
||||
/** End **/
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val superState = super.onSaveInstanceState()
|
||||
return SavedState(superState, currentState, translationY)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
|
||||
/**
|
||||
* ProgressIndicator become INVISIBLE instead of GONE by hide() call.
|
||||
* It`s final so we need this workaround
|
||||
*/
|
||||
class GoneOnInvisibleListener(
|
||||
private val view: View,
|
||||
) : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
if (view.visibility == View.INVISIBLE) {
|
||||
view.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun attach() {
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(this)
|
||||
onGlobalLayout()
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class RetainedLifecycleCoroutineScope(
|
||||
@@ -14,7 +15,9 @@ class RetainedLifecycleCoroutineScope(
|
||||
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
|
||||
|
||||
init {
|
||||
lifecycle.addOnClearedListener(this)
|
||||
launch(Dispatchers.Main.immediate) {
|
||||
lifecycle.addOnClearedListener(this@RetainedLifecycleCoroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
@@ -53,6 +52,7 @@ import okio.use
|
||||
import org.json.JSONException
|
||||
import org.jsoup.internal.StringUtil.StringJoiner
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
@@ -140,7 +140,6 @@ val Context.ramAvailable: Long
|
||||
return result.availMem
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
LocaleConfig(this).supportedLocales?.let {
|
||||
@@ -149,8 +148,7 @@ fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
}
|
||||
val tagsList = StringJoiner(",")
|
||||
try {
|
||||
val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName)
|
||||
val xpp: XmlPullParser = resources.getXml(resId)
|
||||
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
|
||||
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (xpp.eventType == XmlPullParser.START_TAG) {
|
||||
if (xpp.name == "locale") {
|
||||
|
||||
@@ -6,10 +6,13 @@ import android.widget.ImageView
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.annotation.CheckResult
|
||||
import coil3.Extras
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
@@ -28,12 +31,14 @@ 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
|
||||
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): ImageRequest.Builder? {
|
||||
@@ -112,7 +117,7 @@ fun ImageRequest.Builder.bookmarkExtra(bookmark: Bookmark): ImageRequest.Builder
|
||||
fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder {
|
||||
val errorColor = ColorUtils.blendARGB(
|
||||
context.getThemeColor(materialR.attr.colorErrorContainer),
|
||||
context.getThemeColor(materialR.attr.colorBackgroundFloating),
|
||||
context.getThemeColor(appcompatR.attr.colorBackgroundFloating),
|
||||
0.25f,
|
||||
)
|
||||
return placeholder(AnimatedPlaceholderDrawable(context))
|
||||
@@ -162,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,
|
||||
)
|
||||
|
||||
@@ -87,6 +87,10 @@ fun <T, R> Collection<T>.mapSortedByCount(isDescending: Boolean = true, mapper:
|
||||
return sorted.map { it.first }
|
||||
}
|
||||
|
||||
fun Collection<CharSequence?>.contains(element: CharSequence?, ignoreCase: Boolean): Boolean = any { x ->
|
||||
(x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase))
|
||||
}
|
||||
|
||||
fun Collection<CharSequence?>.indexOfContains(element: CharSequence?, ignoreCase: Boolean): Int = indexOfFirst { x ->
|
||||
(x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase))
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
|
||||
return map { list -> list.map(transform) }
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.throttle(timeoutMillis: Long): Flow<T> = throttle { timeoutMillis }
|
||||
|
||||
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
|
||||
var lastEmittedAt = 0L
|
||||
return transformLatest { value ->
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.ResponseBody
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
@@ -30,6 +31,14 @@ suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispa
|
||||
writeAll(source.cancellable())
|
||||
}
|
||||
|
||||
fun BufferedSource.readByteBuffer(): ByteBuffer {
|
||||
val bytes = readByteArray()
|
||||
return ByteBuffer.allocateDirect(bytes.size)
|
||||
.put(bytes)
|
||||
.rewind() as ByteBuffer
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
fun InputStream.toByteBuffer(): ByteBuffer {
|
||||
val outStream = ByteArrayOutputStream(available())
|
||||
copyTo(outStream)
|
||||
|
||||
@@ -21,7 +21,13 @@ inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
|
||||
|
||||
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
|
||||
|
||||
fun String.toLocale() = Locale(this)
|
||||
fun String.toLocale(): Locale = Locale.forLanguageTag(this)
|
||||
|
||||
fun String.toLocaleOrNull() = if (isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
toLocale().takeUnless { it.displayName == this }
|
||||
}
|
||||
|
||||
fun Locale?.getDisplayName(context: Context): String = when (this) {
|
||||
null -> context.getString(R.string.all_languages)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.util.TypedValueCompat
|
||||
@@ -30,7 +31,10 @@ fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean {
|
||||
fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any): String = try {
|
||||
getQuantityString(resId, quantity, *formatArgs)
|
||||
} catch (e: Resources.NotFoundException) {
|
||||
e.report(silent = true)
|
||||
e.printStackTraceDebug()
|
||||
formatArgs.firstOrNull()?.toString() ?: quantity.toString()
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.VANILLA_ICE_CREAM) { // known issue
|
||||
e.printStackTraceDebug()
|
||||
formatArgs.firstOrNull()?.toString() ?: quantity.toString()
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
@@ -91,6 +92,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.appcompat.widget.ActionMenuView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.descendants
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -155,9 +154,9 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
|
||||
|
||||
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
|
||||
if (value) {
|
||||
if (!isVisible) show()
|
||||
show()
|
||||
} else {
|
||||
if (isVisible) hide()
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.data
|
||||
|
||||
import org.koitharu.kotatsu.core.model.getLocale
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -7,6 +8,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import java.util.Locale
|
||||
|
||||
data class MangaDetails(
|
||||
private val manga: Manga,
|
||||
@@ -39,6 +41,13 @@ data class MangaDetails(
|
||||
|
||||
fun toManga() = manga
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
|
||||
fun filterChapters(branch: String?) = MangaDetails(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
@@ -69,4 +78,16 @@ data class MangaDetails(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.TextPaint
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
|
||||
class AuthorSpan(private val listener: OnAuthorClickListener) : ClickableSpan() {
|
||||
|
||||
override fun onClick(widget: View) {
|
||||
val text = (widget as? TextView)?.text as? Spannable ?: return
|
||||
val start = text.getSpanStart(this)
|
||||
val end = text.getSpanEnd(this)
|
||||
val selected = text.substring(start, end).trim()
|
||||
if (selected.isNotEmpty()) {
|
||||
listener.onAuthorClick(selected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {
|
||||
ds.setColor(ds.linkColor)
|
||||
}
|
||||
|
||||
fun interface OnAuthorClickListener {
|
||||
|
||||
fun onAuthorClick(author: String)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.SpannedString
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isGone
|
||||
@@ -43,6 +46,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.image.CoilMemoryCacheKey
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
@@ -100,10 +104,12 @@ import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
@@ -115,7 +121,7 @@ class DetailsActivity :
|
||||
View.OnClickListener,
|
||||
View.OnLayoutChangeListener, ViewTreeObserver.OnDrawListener,
|
||||
ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
SwipeRefreshLayout.OnRefreshListener, AuthorSpan.OnAuthorClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutManager: AppShortcutManager
|
||||
@@ -135,7 +141,6 @@ class DetailsActivity :
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
viewBinding.chipFavorite.setOnClickListener(this)
|
||||
infoBinding.textViewLocal.setOnClickListener(this)
|
||||
infoBinding.textViewAuthor.setOnClickListener(this)
|
||||
infoBinding.textViewSource.setOnClickListener(this)
|
||||
viewBinding.imageViewCover.setOnClickListener(this)
|
||||
viewBinding.textViewTitle.setOnClickListener(this)
|
||||
@@ -145,6 +150,7 @@ class DetailsActivity :
|
||||
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
|
||||
viewBinding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
|
||||
infoBinding.textViewAuthor.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
viewBinding.chipsTags.onChipClickListener = this
|
||||
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
|
||||
@@ -179,16 +185,6 @@ class DetailsActivity :
|
||||
viewModel.isStatsAvailable.observe(this, menuInvalidator)
|
||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||
viewModel.tags.observe(this, ::onTagsChanged)
|
||||
viewModel.branches.observe(this) {
|
||||
val branch = it.singleOrNull()
|
||||
infoBinding.textViewTranslation.textAndVisible = branch?.name
|
||||
infoBinding.textViewTranslation.drawableStart = branch?.locale?.let {
|
||||
LocaleUtils.getEmojiFlag(it)
|
||||
}?.let {
|
||||
TextDrawable.compound(infoBinding.textViewTranslation, it)
|
||||
}
|
||||
infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible
|
||||
}
|
||||
viewModel.chapters.observe(this, PrefetchObserver(this))
|
||||
viewModel.onDownloadStarted
|
||||
.filterNot { appRouter.isChapterPagesSheetShown() }
|
||||
@@ -202,36 +198,31 @@ class DetailsActivity :
|
||||
addMenuProvider(menuProvider)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true }
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT }
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.textView_author -> {
|
||||
val manga = viewModel.manga.value
|
||||
val author = manga?.author ?: return
|
||||
router.showAuthorDialog(author, manga.source)
|
||||
}
|
||||
|
||||
R.id.textView_source -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openList(manga.source, null, null)
|
||||
}
|
||||
|
||||
R.id.textView_local -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.showLocalInfoDialog(manga)
|
||||
}
|
||||
|
||||
R.id.chip_favorite -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.showFavoriteDialog(manga)
|
||||
}
|
||||
|
||||
R.id.imageView_cover -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openImage(
|
||||
url = viewModel.coverUrl.value ?: return,
|
||||
source = manga.source,
|
||||
preview = CoilMemoryCacheKey.from(viewBinding.imageViewCover),
|
||||
anchor = v,
|
||||
)
|
||||
}
|
||||
@@ -251,17 +242,17 @@ class DetailsActivity :
|
||||
}
|
||||
|
||||
R.id.button_scrobbling_more -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.showScrobblingSelectorSheet(manga, null)
|
||||
}
|
||||
|
||||
R.id.button_related_more -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openRelated(manga)
|
||||
}
|
||||
|
||||
R.id.textView_title -> {
|
||||
val title = viewModel.manga.value?.title?.nullIfEmpty() ?: return
|
||||
val title = viewModel.getMangaOrNull()?.title?.nullIfEmpty() ?: return
|
||||
buildAlertDialog(this) {
|
||||
setMessage(title)
|
||||
setNegativeButton(R.string.close, null)
|
||||
@@ -273,6 +264,10 @@ class DetailsActivity :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthorClick(author: String) {
|
||||
router.showAuthorDialog(author, viewModel.getMangaOrNull()?.source ?: return)
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val tag = data as? MangaTag ?: return
|
||||
router.showTagDialog(tag)
|
||||
@@ -306,7 +301,6 @@ class DetailsActivity :
|
||||
oldBottom: Int
|
||||
) {
|
||||
with(viewBinding) {
|
||||
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
|
||||
containerBottomSheet?.let { sheet ->
|
||||
val peekHeight = BottomSheetBehavior.from(sheet).peekHeight
|
||||
if (scrollView.paddingBottom != peekHeight) {
|
||||
@@ -407,11 +401,21 @@ class DetailsActivity :
|
||||
with(viewBinding) {
|
||||
textViewTitle.text = manga.title
|
||||
textViewSubtitle.textAndVisible = manga.altTitles.joinToString("\n")
|
||||
textViewNsfw.isVisible = manga.isNsfw
|
||||
textViewNsfw16.isVisible = manga.contentRating == ContentRating.SUGGESTIVE
|
||||
textViewNsfw18.isVisible = manga.contentRating == ContentRating.ADULT
|
||||
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
|
||||
}
|
||||
with(infoBinding) {
|
||||
textViewAuthor.textAndVisible = manga.author
|
||||
val translation = details.getLocale()
|
||||
infoBinding.textViewTranslation.textAndVisible = translation?.getDisplayLanguage(translation)
|
||||
?.toTitleCase(translation)
|
||||
infoBinding.textViewTranslation.drawableStart = translation?.let {
|
||||
LocaleUtils.getEmojiFlag(it)
|
||||
}?.let {
|
||||
TextDrawable.compound(infoBinding.textViewTranslation, it)
|
||||
}
|
||||
infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible
|
||||
textViewAuthor.textAndVisible = manga.getAuthorsString()
|
||||
textViewAuthorLabel.isVisible = textViewAuthor.isVisible
|
||||
if (manga.hasRating) {
|
||||
ratingBarRating.rating = manga.rating * ratingBarRating.numStars
|
||||
@@ -533,6 +537,24 @@ class DetailsActivity :
|
||||
return getString(R.string.chapters_time_pattern, this, timeFormatted)
|
||||
}
|
||||
|
||||
private fun Manga.getAuthorsString(): SpannedString? {
|
||||
if (authors.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return buildSpannedString {
|
||||
authors.forEach { a ->
|
||||
if (a.isNotEmpty()) {
|
||||
if (isNotEmpty()) {
|
||||
append(", ")
|
||||
}
|
||||
inSpans(AuthorSpan(this@DetailsActivity)) {
|
||||
append(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.nullIfEmpty()
|
||||
}
|
||||
|
||||
private class PrefetchObserver(
|
||||
private val context: Context,
|
||||
) : FlowCollector<List<ChapterListItem>?> {
|
||||
|
||||
@@ -26,6 +26,7 @@ class DetailsErrorObserver(
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
snackbar.setAnchorView(activity.viewBinding.containerBottomSheet)
|
||||
if (value is NotFoundException || value is UnsupportedSourceException) {
|
||||
snackbar.duration = Snackbar.LENGTH_INDEFINITE
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
|
||||
class DetailsMenuProvider(
|
||||
private val activity: FragmentActivity,
|
||||
@@ -36,7 +37,7 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga)
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true
|
||||
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
@@ -69,7 +70,7 @@ class DetailsMenuProvider(
|
||||
}
|
||||
|
||||
R.id.action_online -> {
|
||||
router.openDetails(manga)
|
||||
router.openDetails(viewModel.remoteManga.value ?: return false)
|
||||
}
|
||||
|
||||
R.id.action_related -> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -15,16 +15,17 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
|
||||
private val radius = context.resources.getDimension(appcompatR.dimen.abc_control_corner_material)
|
||||
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
|
||||
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
|
||||
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
@@ -32,7 +33,7 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
|
||||
|
||||
init {
|
||||
paint.color = ColorUtils.setAlphaComponent(
|
||||
context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY),
|
||||
context.getThemeColor(appcompatR.attr.colorPrimary, Color.DKGRAY),
|
||||
98,
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.util.Locale
|
||||
|
||||
data class MangaBranch(
|
||||
val name: String?,
|
||||
@@ -11,8 +10,6 @@ data class MangaBranch(
|
||||
val isCurrent: Boolean,
|
||||
) : ListModel {
|
||||
|
||||
val locale: Locale? by lazy(::findAppropriateLocale)
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaBranch && other.name == name
|
||||
}
|
||||
@@ -28,16 +25,4 @@ data class MangaBranch(
|
||||
override fun toString(): String {
|
||||
return "$name: $count"
|
||||
}
|
||||
|
||||
private fun findAppropriateLocale(): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -27,7 +26,6 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.menuView
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
@@ -106,9 +104,6 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
}
|
||||
val binding = viewBinding ?: return
|
||||
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
|
||||
if (sheet.context.isAnimationsEnabled) {
|
||||
TransitionManager.beginDelayedTransition(binding.toolbar)
|
||||
}
|
||||
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
|
||||
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted
|
||||
&& viewModel is DetailsViewModel
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -71,7 +71,7 @@ class BookmarksViewModel @Inject constructor(
|
||||
if (b.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
result += ListHeader(chapter.name)
|
||||
result += ListHeader(chapter)
|
||||
result.addAll(b)
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
|
||||
@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.transformations
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
@@ -43,6 +45,7 @@ fun pageThumbnailAD(
|
||||
size(thumbSize)
|
||||
scale(Scale.FILL)
|
||||
allowRgb565(true)
|
||||
transformations(TrimTransformation())
|
||||
decodeRegion(0)
|
||||
mangaSourceExtra(item.page.source)
|
||||
enqueueWith(coil)
|
||||
|
||||
@@ -130,7 +130,7 @@ class PagesViewModel @Inject constructor(
|
||||
for (page in snapshot) {
|
||||
if (page.chapterId != previousChapterId) {
|
||||
chaptersLoader.peekChapter(page.chapterId)?.let {
|
||||
add(ListHeader(it.name))
|
||||
add(ListHeader(it))
|
||||
}
|
||||
previousChapterId = page.chapterId
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
data class DownloadItemModel(
|
||||
val id: UUID,
|
||||
@@ -62,7 +62,7 @@ data class DownloadItemModel(
|
||||
fun getErrorMessage(context: Context): CharSequence? = if (error != null) {
|
||||
buildSpannedString {
|
||||
bold {
|
||||
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
||||
color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) {
|
||||
append(error)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ data class DownloadItemModel(
|
||||
}
|
||||
|
||||
override fun compareTo(other: DownloadItemModel): Int {
|
||||
return timestamp.compareTo(other.timestamp)
|
||||
return timestamp compareTo other.timestamp
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
@@ -23,7 +24,7 @@ class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDeco
|
||||
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
|
||||
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
|
||||
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.withTransaction
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -354,7 +353,7 @@ class MangaSourcesRepository @Inject constructor(
|
||||
.conflate()
|
||||
}
|
||||
|
||||
private fun getExternalSources() = context.packageManager.queryIntentContentProviders(
|
||||
fun getExternalSources(): List<ExternalMangaSource> = context.packageManager.queryIntentContentProviders(
|
||||
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
|
||||
).map { resolveInfo ->
|
||||
ExternalMangaSource(
|
||||
|
||||
@@ -36,15 +36,15 @@ class RecoverMangaUseCase @Inject constructor(
|
||||
) = Manga(
|
||||
id = broken.id,
|
||||
title = current.title,
|
||||
altTitle = current.altTitle,
|
||||
altTitles = current.altTitles,
|
||||
url = current.url,
|
||||
publicUrl = current.publicUrl,
|
||||
rating = current.rating,
|
||||
isNsfw = current.isNsfw,
|
||||
contentRating = current.contentRating,
|
||||
coverUrl = current.coverUrl,
|
||||
tags = current.tags,
|
||||
state = current.state,
|
||||
author = current.author,
|
||||
authors = current.authors,
|
||||
largeCoverUrl = current.largeCoverUrl,
|
||||
description = current.description,
|
||||
chapters = current.chapters,
|
||||
|
||||
@@ -14,12 +14,13 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class SourceSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
|
||||
@@ -13,13 +13,14 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class CategoriesSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val radius = context.resources.getDimension(R.dimen.list_selector_corner)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.setThemeTextAppearance
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ViewFilterFieldBinding
|
||||
import java.util.LinkedList
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class FilterFieldLayout @JvmOverloads constructor(
|
||||
@@ -100,7 +101,7 @@ class FilterFieldLayout @JvmOverloads constructor(
|
||||
label.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_error_small)
|
||||
TextViewCompat.setCompoundDrawableTintList(
|
||||
label,
|
||||
context.getThemeColorStateList(materialR.attr.colorControlNormal),
|
||||
context.getThemeColorStateList(appcompatR.attr.colorControlNormal),
|
||||
)
|
||||
addView(label)
|
||||
errorView = label
|
||||
|
||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
class FilterHeaderProducer @Inject constructor(
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
@@ -129,7 +129,7 @@ class FilterHeaderProducer @Inject constructor(
|
||||
result.addFirst(
|
||||
ChipsView.ChipModel(
|
||||
title = snapshot.query,
|
||||
icon = materialR.drawable.abc_ic_search_api_material,
|
||||
icon = appcompatR.drawable.abc_ic_search_api_material,
|
||||
isCloseable = true,
|
||||
data = snapshot.query,
|
||||
),
|
||||
|
||||
@@ -11,21 +11,20 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.lifecycle
|
||||
import coil3.target.ViewTarget
|
||||
import coil3.target.GenericViewTarget
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.image.CoilMemoryCacheKey
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
@@ -36,6 +35,7 @@ import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -44,7 +44,7 @@ import org.koitharu.kotatsu.core.util.ext.start
|
||||
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ImageActivity : BaseActivity<ActivityImageBinding>(),
|
||||
@@ -63,7 +63,6 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
|
||||
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||
viewBinding.buttonBack.setOnClickListener(this)
|
||||
viewBinding.buttonMenu.setOnClickListener(this)
|
||||
val imageUrl = requireNotNull(intent.data)
|
||||
|
||||
val menuProvider = ImageMenuProvider(
|
||||
activity = this,
|
||||
@@ -74,14 +73,14 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
|
||||
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
|
||||
viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
|
||||
loadImage(imageUrl)
|
||||
loadImage()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_back -> dispatchNavigateUp()
|
||||
R.id.button_menu -> menuMediator.onLongClick(v)
|
||||
else -> loadImage(intent.data)
|
||||
else -> loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +121,11 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
|
||||
return insets.consumeAll(typeMask)
|
||||
}
|
||||
|
||||
private fun loadImage(url: Uri?) {
|
||||
private fun loadImage() {
|
||||
ImageRequest.Builder(this)
|
||||
.data(url)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.data(intent.data)
|
||||
.memoryCacheKey(intent.getParcelableExtraCompat<CoilMemoryCacheKey>(AppRouter.KEY_PREVIEW)?.data)
|
||||
.memoryCachePolicy(CachePolicy.READ_ONLY)
|
||||
.lifecycle(this)
|
||||
.listener(this)
|
||||
.mangaSourceExtra(MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)))
|
||||
@@ -147,22 +147,24 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
|
||||
it.setColorSchemeColors(getThemeColor(appcompatR.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
|
||||
button.setImageResource(appcompatR.drawable.abc_ic_menu_overflow_material)
|
||||
}
|
||||
}
|
||||
|
||||
private class SsivTarget(
|
||||
override val view: SubsamplingScaleImageView,
|
||||
) : ViewTarget<SubsamplingScaleImageView> {
|
||||
) : GenericViewTarget<SubsamplingScaleImageView>() {
|
||||
|
||||
override fun onError(error: Image?) = setDrawable(error?.asDrawable(view.resources))
|
||||
|
||||
override fun onSuccess(result: Image) = setDrawable(result.asDrawable(view.resources))
|
||||
override var drawable: Drawable? = null
|
||||
set(value) {
|
||||
field = value
|
||||
setImageDrawable(value)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return (this === other) || (other is SsivTarget && view == other.view)
|
||||
@@ -172,7 +174,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
|
||||
|
||||
override fun toString() = "SsivTarget(view=$view)"
|
||||
|
||||
private fun setDrawable(drawable: Drawable?) {
|
||||
private fun setImageDrawable(drawable: Drawable?) {
|
||||
if (drawable != null) {
|
||||
view.setImage(ImageSource.bitmap(drawable.toBitmap()))
|
||||
} else {
|
||||
|
||||
@@ -140,7 +140,7 @@ class MangaListMapper @Inject constructor(
|
||||
|
||||
@ColorRes
|
||||
private fun getTagTint(tag: MangaTag): Int {
|
||||
return if (tag.title.lowercase() in dict) {
|
||||
return if (settings.isTagsWarningsEnabled && tag.title.lowercase() in dict) {
|
||||
R.color.warning
|
||||
} else {
|
||||
0
|
||||
@@ -148,7 +148,7 @@ class MangaListMapper @Inject constructor(
|
||||
}
|
||||
|
||||
private fun readTagsDict(context: Context): ScatterSet<String> =
|
||||
context.resources.openRawResource(R.raw.tags_redlist).use {
|
||||
context.resources.openRawResource(R.raw.tags_warnlist).use {
|
||||
val set = MutableScatterSet<String>()
|
||||
it.bufferedReader().forEachLine { x ->
|
||||
val line = x.trim()
|
||||
|
||||
@@ -15,12 +15,13 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import androidx.appcompat.R as appcompatR
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
protected val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
|
||||
protected val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
|
||||
@@ -32,7 +32,7 @@ fun mangaListDetailedItemAD(
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.title
|
||||
binding.textViewAuthor.textAndVisible = item.manga.author
|
||||
binding.textViewAuthor.textAndVisible = item.manga.authors.joinToString(", ")
|
||||
binding.progressView.setProgress(
|
||||
value = item.progress,
|
||||
animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.model.getLocalizedTitle
|
||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
|
||||
data class ListHeader private constructor(
|
||||
private val textRaw: Any,
|
||||
@@ -25,6 +27,13 @@ data class ListHeader private constructor(
|
||||
badge: String? = null,
|
||||
) : this(textRaw = textRes, buttonTextRes, payload, badge)
|
||||
|
||||
constructor(
|
||||
chapter: MangaChapter,
|
||||
@StringRes buttonTextRes: Int = 0,
|
||||
payload: Any? = null,
|
||||
badge: String? = null,
|
||||
) : this(textRaw = chapter, buttonTextRes, payload, badge)
|
||||
|
||||
constructor(
|
||||
dateTimeAgo: DateTimeAgo,
|
||||
@StringRes buttonTextRes: Int = 0,
|
||||
@@ -36,6 +45,7 @@ data class ListHeader private constructor(
|
||||
is CharSequence -> textRaw
|
||||
is Int -> if (textRaw != 0) context.getString(textRaw) else null
|
||||
is DateTimeAgo -> textRaw.format(context)
|
||||
is MangaChapter -> textRaw.getLocalizedTitle(context.resources)
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -17,7 +16,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.isWriteable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
@@ -45,6 +43,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val FILENAME_SKIP = ".notamanga"
|
||||
|
||||
@Singleton
|
||||
class LocalMangaRepository @Inject constructor(
|
||||
@@ -140,7 +139,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun delete(manga: Manga): Boolean {
|
||||
val file = Uri.parse(manga.url).toFile()
|
||||
val file = manga.url.toUri().toFile()
|
||||
val result = file.deleteAwait()
|
||||
if (result) {
|
||||
localMangaIndex.delete(manga.id)
|
||||
@@ -256,8 +255,10 @@ class LocalMangaRepository @Inject constructor(
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||
.asSequence()
|
||||
.flatMap { dir ->
|
||||
dir.withChildren { children -> children.filterNot { it.isHidden }.toList() }
|
||||
dir.withChildren { children -> children.filterNot { it.isHidden || it.shouldSkip() }.toList() }
|
||||
}
|
||||
|
||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||
|
||||
private fun File.shouldSkip(): Boolean = isDirectory && File(this, FILENAME_SKIP).exists()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local.domain.model
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import org.koitharu.kotatsu.core.util.ext.contains
|
||||
import org.koitharu.kotatsu.core.util.ext.creationTime
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
@@ -26,8 +27,8 @@ data class LocalManga(
|
||||
|
||||
fun isMatchesQuery(query: String): Boolean {
|
||||
return manga.title.contains(query, ignoreCase = true) ||
|
||||
manga.altTitle?.contains(query, ignoreCase = true) == true ||
|
||||
manga.author?.contains(query, ignoreCase = true) == true
|
||||
manga.altTitles.contains(query, ignoreCase = true) ||
|
||||
manga.authors.contains(query, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun containsTags(tags: Collection<String>): Boolean {
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), View.OnClickListener {
|
||||
@@ -86,7 +86,7 @@ class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), View.OnCl
|
||||
val total = size + available
|
||||
val segment = SegmentedBarView.Segment(
|
||||
percent = (size.toDouble() / total.toDouble()).toFloat(),
|
||||
color = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary),
|
||||
color = KotatsuColors.segmentColor(view.context, appcompatR.attr.colorPrimary),
|
||||
)
|
||||
requireViewBinding().labelUsed.text = view.context.getString(
|
||||
R.string.memory_usage_pattern,
|
||||
|
||||
@@ -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.abc_ic_ab_back_material
|
||||
appcompatR.drawable.abc_ic_ab_back_material
|
||||
} else {
|
||||
materialR.drawable.abc_ic_search_api_material
|
||||
appcompatR.drawable.abc_ic_search_api_material
|
||||
},
|
||||
)
|
||||
setHomeActionContentDescription(
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -26,6 +25,7 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.NavItem
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
|
||||
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
|
||||
import org.koitharu.kotatsu.explore.ui.ExploreFragment
|
||||
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
|
||||
@@ -232,7 +232,7 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
private fun setNavbarIsLabeled(value: Boolean) {
|
||||
if (navBar is BottomNavigationView) {
|
||||
if (navBar is SlidingBottomNavigationView) {
|
||||
navBar.minimumHeight = navBar.resources.getDimensionPixelSize(
|
||||
if (value) {
|
||||
materialR.dimen.m3_bottom_nav_min_height
|
||||
|
||||
@@ -8,20 +8,6 @@ fun Manga.filterChapters(branch: String?): Manga {
|
||||
return withChapters(chapters = chapters?.filter { it.branch == branch })
|
||||
}
|
||||
|
||||
private fun Manga.withChapters(chapters: List<MangaChapter>?) = Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
private fun Manga.withChapters(chapters: List<MangaChapter>?) = copy(
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,14 +3,22 @@ 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
|
||||
import androidx.core.net.toUri
|
||||
import coil3.BitmapImage
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import coil3.toBitmap
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import dagger.hilt.android.ActivityRetainedLifecycle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Deferred
|
||||
@@ -36,9 +44,9 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
|
||||
import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin
|
||||
import org.koitharu.kotatsu.core.util.ext.compressToPNG
|
||||
@@ -49,6 +57,8 @@ import org.koitharu.kotatsu.core.util.ext.isFileUri
|
||||
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
|
||||
import org.koitharu.kotatsu.core.util.ext.isZipUri
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.ramAvailable
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
||||
@@ -76,13 +86,14 @@ class PageLoader @Inject constructor(
|
||||
lifecycle: ActivityRetainedLifecycle,
|
||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val coil: ImageLoader,
|
||||
private val settings: AppSettings,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher,
|
||||
) {
|
||||
|
||||
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
|
||||
val loaderScope = lifecycle.lifecycleScope + InternalErrorHandler() + Dispatchers.Default
|
||||
|
||||
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
|
||||
private val semaphore = Semaphore(3)
|
||||
@@ -121,6 +132,41 @@ class PageLoader @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadPreview(page: MangaPage): ImageSource? {
|
||||
val preview = page.preview
|
||||
if (preview.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(preview)
|
||||
.mangaSourceExtra(page.source)
|
||||
.transformations(TrimTransformation())
|
||||
.build()
|
||||
return coil.execute(request).image?.toImageSource()
|
||||
}
|
||||
|
||||
fun peekPreviewSource(preview: String?): ImageSource? {
|
||||
if (preview.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
coil.memoryCache?.let { cache ->
|
||||
val key = MemoryCache.Key(preview)
|
||||
cache[key]?.image?.let {
|
||||
return if (it is BitmapImage) {
|
||||
ImageSource.cachedBitmap(it.toBitmap())
|
||||
} else {
|
||||
ImageSource.bitmap(it.toBitmap())
|
||||
}
|
||||
}
|
||||
}
|
||||
coil.diskCache?.let { cache ->
|
||||
cache.openSnapshot(preview)?.use { snapshot ->
|
||||
return ImageSource.file(snapshot.data.toFile())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
|
||||
var task = tasks[page.id]?.takeIf { it.isValid() }
|
||||
if (force) {
|
||||
@@ -139,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)
|
||||
@@ -149,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) {
|
||||
@@ -237,7 +285,7 @@ class PageLoader @Inject constructor(
|
||||
if (!skipCache) {
|
||||
cache.get(pageUrl)?.let { return it.toUri() }
|
||||
}
|
||||
val uri = Uri.parse(pageUrl)
|
||||
val uri = pageUrl.toUri()
|
||||
return when {
|
||||
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
|
||||
uri
|
||||
@@ -264,6 +312,12 @@ class PageLoader @Inject constructor(
|
||||
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
|
||||
}
|
||||
|
||||
private fun Image.toImageSource(): ImageSource = if (this is BitmapImage) {
|
||||
ImageSource.cachedBitmap(toBitmap())
|
||||
} else {
|
||||
ImageSource.bitmap(toBitmap())
|
||||
}
|
||||
|
||||
private fun Deferred<Uri>.isValid(): Boolean {
|
||||
return getCompletionResultOrNull()?.map { uri ->
|
||||
uri.exists() && uri.isTargetNotEmpty()
|
||||
|
||||
@@ -127,8 +127,10 @@ class ReaderActionsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
isSliderChanged = false
|
||||
isSliderTracking = true
|
||||
if (!isSliderTracking) {
|
||||
isSliderChanged = false
|
||||
isSliderTracking = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
|
||||
@@ -105,7 +105,7 @@ class ReaderActivity :
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
|
||||
setDisplayHomeAsUp(true, false)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
touchHelper = TapGridDispatcher(this, this)
|
||||
scrollTimer = scrollTimerFactory.create(this, this)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
@@ -146,7 +146,7 @@ class ReaderActivity :
|
||||
.setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
viewModel.readerSettings.observe(this) {
|
||||
viewModel.readerSettingsProducer.observe(this) {
|
||||
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
|
||||
}
|
||||
viewModel.isZoomControlsEnabled.observe(this) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -50,6 +51,7 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
@@ -84,6 +86,7 @@ class ReaderViewModel @Inject constructor(
|
||||
interactor: DetailsInteractor,
|
||||
deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
readerSettingsProducerFactory: ReaderSettings.Producer.Factory,
|
||||
) : ChaptersPagesViewModel(
|
||||
settings = settings,
|
||||
interactor = interactor,
|
||||
@@ -169,15 +172,11 @@ 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?.isNsfw == true }
|
||||
val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT }
|
||||
|
||||
val isBookmarkAdded = readingState.flatMapLatest { state ->
|
||||
val manga = mangaDetails.value?.toManga()
|
||||
|
||||
@@ -1,66 +1,69 @@
|
||||
package org.koitharu.kotatsu.reader.ui.config
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.view.View
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.collection.scatterSetOf
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderBackground
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
|
||||
class ReaderSettings(
|
||||
private val parentScope: CoroutineScope,
|
||||
private val settings: AppSettings,
|
||||
private val colorFilterFlow: StateFlow<ReaderColorFilter?>,
|
||||
) : MediatorLiveData<ReaderSettings>() {
|
||||
data class ReaderSettings(
|
||||
val zoomMode: ZoomMode,
|
||||
val background: ReaderBackground,
|
||||
val colorFilter: ReaderColorFilter?,
|
||||
val isReaderOptimizationEnabled: Boolean,
|
||||
val bitmapConfig: Bitmap.Config,
|
||||
val isPagesNumbersEnabled: Boolean,
|
||||
val isPagesCropEnabledStandard: Boolean,
|
||||
val isPagesCropEnabledWebtoon: Boolean,
|
||||
) {
|
||||
|
||||
private val internalObserver = InternalObserver()
|
||||
private var collectJob: Job? = null
|
||||
|
||||
val zoomMode: ZoomMode
|
||||
get() = settings.zoomMode
|
||||
|
||||
val background: ReaderBackground
|
||||
get() = settings.readerBackground
|
||||
|
||||
val colorFilter: ReaderColorFilter?
|
||||
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter
|
||||
|
||||
val isReaderOptimizationEnabled: Boolean
|
||||
get() = settings.isReaderOptimizationEnabled
|
||||
|
||||
val bitmapConfig: Bitmap.Config
|
||||
get() = if (settings.is32BitColorsEnabled) {
|
||||
private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this(
|
||||
zoomMode = settings.zoomMode,
|
||||
background = settings.readerBackground,
|
||||
colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter,
|
||||
isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled,
|
||||
bitmapConfig = if (settings.is32BitColorsEnabled) {
|
||||
Bitmap.Config.ARGB_8888
|
||||
} else {
|
||||
Bitmap.Config.RGB_565
|
||||
}
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = settings.isPagesNumbersEnabled
|
||||
},
|
||||
isPagesNumbersEnabled = settings.isPagesNumbersEnabled,
|
||||
isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD),
|
||||
isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON),
|
||||
)
|
||||
|
||||
fun applyBackground(view: View) {
|
||||
view.background = background.resolve(view.context)
|
||||
}
|
||||
|
||||
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
|
||||
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
|
||||
)
|
||||
fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) {
|
||||
isPagesCropEnabledWebtoon
|
||||
} else {
|
||||
isPagesCropEnabledStandard
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
|
||||
@@ -78,33 +81,13 @@ class ReaderSettings(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
settings.unsubscribe(internalObserver)
|
||||
collectJob?.cancel()
|
||||
collectJob = null
|
||||
}
|
||||
class Producer @AssistedInject constructor(
|
||||
@Assisted private val mangaId: Flow<Long>,
|
||||
private val settings: AppSettings,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) : MediatorStateFlow<ReaderSettings>(ReaderSettings(settings, null)) {
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
settings.subscribe(internalObserver)
|
||||
collectJob?.cancel()
|
||||
collectJob = parentScope.launch {
|
||||
colorFilterFlow.collect(internalObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValue() = this
|
||||
|
||||
private fun notifyChanged() {
|
||||
value = value
|
||||
}
|
||||
|
||||
private inner class InternalObserver :
|
||||
FlowCollector<ReaderColorFilter?>,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val settingsKeys = setOf(
|
||||
private val settingsKeys = scatterSetOf(
|
||||
AppSettings.KEY_ZOOM_MODE,
|
||||
AppSettings.KEY_PAGES_NUMBERS,
|
||||
AppSettings.KEY_READER_BACKGROUND,
|
||||
@@ -114,18 +97,38 @@ class ReaderSettings(
|
||||
AppSettings.KEY_CF_BRIGHTNESS,
|
||||
AppSettings.KEY_CF_INVERTED,
|
||||
AppSettings.KEY_CF_GRAYSCALE,
|
||||
AppSettings.KEY_READER_CROP,
|
||||
)
|
||||
private var job: Job? = null
|
||||
|
||||
override suspend fun emit(value: ReaderColorFilter?) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
notifyChanged()
|
||||
override fun onActive() {
|
||||
assert(job?.isActive != true)
|
||||
job?.cancel()
|
||||
job = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
observeImpl()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key in settingsKeys) {
|
||||
notifyChanged()
|
||||
override fun onInactive() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
private suspend fun observeImpl() {
|
||||
combine(
|
||||
mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) },
|
||||
settings.observe().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
|
||||
) { mangaCf, settingsKey ->
|
||||
ReaderSettings(settings, mangaCf)
|
||||
}.collect {
|
||||
publishValue(it)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(mangaId: Flow<Long>): Producer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,58 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.view.View
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
|
||||
import org.koitharu.kotatsu.reader.ui.pager.vm.PageState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.vm.PageViewModel
|
||||
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
|
||||
|
||||
abstract class BasePageHolder<B : ViewBinding>(
|
||||
protected val binding: B,
|
||||
loader: PageLoader,
|
||||
protected val settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
|
||||
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener, ComponentCallbacks2 {
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
protected val delegate = PageHolderDelegate(
|
||||
protected val viewModel = PageViewModel(
|
||||
loader = loader,
|
||||
readerSettings = settings,
|
||||
callback = this,
|
||||
settingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
isWebtoon = this is WebtoonHolder,
|
||||
)
|
||||
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
|
||||
protected abstract val ssiv: SubsamplingScaleImageView
|
||||
|
||||
protected val settings: ReaderSettings
|
||||
get() = viewModel.settingsProducer.value
|
||||
|
||||
val context: Context
|
||||
get() = itemView.context
|
||||
@@ -42,51 +60,139 @@ abstract class BasePageHolder<B : ViewBinding>(
|
||||
var boundData: ReaderPage? = null
|
||||
private set
|
||||
|
||||
override fun onConfigChanged() {
|
||||
settings.applyBackground(itemView)
|
||||
init {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
ssiv.bindToLifecycle(this@BasePageHolder)
|
||||
ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
|
||||
ssiv.addOnImageEventListener(viewModel)
|
||||
ssiv.addOnImageEventListener(this@BasePageHolder)
|
||||
}
|
||||
val clickListener = View.OnClickListener { v ->
|
||||
when (v.id) {
|
||||
R.id.button_retry -> viewModel.retry(
|
||||
page = boundData?.toMangaPage() ?: return@OnClickListener,
|
||||
isFromUser = true,
|
||||
)
|
||||
|
||||
R.id.button_error_details -> viewModel.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
bindingInfo.buttonRetry.setOnClickListener(clickListener)
|
||||
bindingInfo.buttonErrorDetails.setOnClickListener(clickListener)
|
||||
}
|
||||
|
||||
fun requireData(): ReaderPage {
|
||||
return checkNotNull(boundData) { "Calling requireData() before bind()" }
|
||||
@CallSuper
|
||||
protected open fun onConfigChanged(settings: ReaderSettings) {
|
||||
settings.applyBackground(itemView)
|
||||
if (settings.applyBitmapConfig(ssiv)) {
|
||||
reloadImage()
|
||||
} else if (viewModel.state.value is PageState.Shown) {
|
||||
onReady()
|
||||
}
|
||||
ssiv.applyDownSampling(isResumed())
|
||||
}
|
||||
|
||||
fun reloadImage() {
|
||||
val source = (viewModel.state.value as? PageState.Shown)?.source ?: return
|
||||
ssiv.setImage(source)
|
||||
}
|
||||
|
||||
fun bind(data: ReaderPage) {
|
||||
boundData = data
|
||||
viewModel.onBind(data.toMangaPage())
|
||||
onBind(data)
|
||||
}
|
||||
|
||||
protected abstract fun onBind(data: ReaderPage)
|
||||
@CallSuper
|
||||
protected open fun onBind(data: ReaderPage) = Unit
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
context.registerComponentCallbacks(delegate)
|
||||
context.registerComponentCallbacks(this)
|
||||
viewModel.state.observe(this, ::onStateChanged)
|
||||
viewModel.settingsProducer.observe(this, ::onConfigChanged)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (delegate.state == State.ERROR && !delegate.isLoading()) {
|
||||
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) }
|
||||
ssiv.applyDownSampling(isForeground = true)
|
||||
if (viewModel.state.value is PageState.Error && !viewModel.isLoading()) {
|
||||
boundData?.let { viewModel.retry(it.toMangaPage(), isFromUser = false) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
ssiv.applyDownSampling(isForeground = false)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
context.unregisterComponentCallbacks(delegate)
|
||||
context.unregisterComponentCallbacks(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onAttachedToWindow() {
|
||||
delegate.onAttachedToWindow()
|
||||
}
|
||||
open fun onAttachedToWindow() = Unit
|
||||
|
||||
@CallSuper
|
||||
open fun onDetachedFromWindow() {
|
||||
delegate.onDetachedFromWindow()
|
||||
}
|
||||
open fun onDetachedFromWindow() = Unit
|
||||
|
||||
@CallSuper
|
||||
open fun onRecycled() {
|
||||
delegate.onRecycle()
|
||||
viewModel.onRecycle()
|
||||
ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
final override fun onLowMemory() = onTrimMemory(TRIM_MEMORY_COMPLETE)
|
||||
|
||||
protected open fun onStateChanged(state: PageState) {
|
||||
bindingInfo.layoutError.isVisible = state is PageState.Error
|
||||
bindingInfo.layoutProgress.isGone = state.isFinalState()
|
||||
val progress = (state as? PageState.Loading)?.progress ?: -1
|
||||
if (progress in 0..100) {
|
||||
bindingInfo.progressBar.isIndeterminate = false
|
||||
bindingInfo.progressBar.setProgressCompat(progress, true)
|
||||
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
|
||||
} else {
|
||||
bindingInfo.progressBar.isIndeterminate = true
|
||||
bindingInfo.textViewStatus.setText(R.string.loading_)
|
||||
}
|
||||
when (state) {
|
||||
is PageState.Converting -> {
|
||||
bindingInfo.textViewStatus.setText(R.string.processing_)
|
||||
}
|
||||
|
||||
is PageState.Empty -> Unit
|
||||
|
||||
is PageState.Error -> {
|
||||
val e = state.error
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
is PageState.Loaded -> {
|
||||
bindingInfo.textViewStatus.setText(R.string.preparing_)
|
||||
ssiv.setImage(state.source)
|
||||
}
|
||||
|
||||
is PageState.Loading -> {
|
||||
if (state.preview != null && ssiv.getState() == null) {
|
||||
ssiv.setImage(state.preview)
|
||||
}
|
||||
}
|
||||
|
||||
is PageState.Shown -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {
|
||||
|
||||
@@ -142,7 +142,7 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
|
||||
override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import kotlin.coroutines.suspendCoroutine
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
private val loader: PageLoader,
|
||||
private val readerSettings: ReaderSettings,
|
||||
private val readerSettingsProducer: ReaderSettings.Producer,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
) : RecyclerView.Adapter<H>() {
|
||||
@@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
final override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int,
|
||||
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
|
||||
): H = onCreateViewHolder(parent, loader, readerSettingsProducer, networkState, exceptionResolver)
|
||||
|
||||
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
|
||||
differ.submitList(items) {
|
||||
@@ -69,7 +69,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
protected abstract fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
): H
|
||||
|
||||
@@ -1,247 +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 kotlinx.coroutines.yield
|
||||
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() {
|
||||
state = State.SHOWING
|
||||
error = null
|
||||
callback.onImageShowing(readerSettings)
|
||||
}
|
||||
|
||||
override fun onImageLoaded() {
|
||||
state = State.SHOWN
|
||||
error = null
|
||||
callback.onImageShown()
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
state = State.LOADING
|
||||
error = null
|
||||
callback.onLoadingStarted()
|
||||
yield()
|
||||
try {
|
||||
val task = withContext(Dispatchers.Default) {
|
||||
loader.loadPageAsync(data, force)
|
||||
}
|
||||
uri = coroutineScope {
|
||||
val progressObserver = observeProgress(this, task.progressAsFlow())
|
||||
val file = task.await()
|
||||
progressObserver.cancelAndJoin()
|
||||
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, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun onLoadingStarted()
|
||||
|
||||
fun onError(e: Throwable)
|
||||
|
||||
fun onImageReady(source: ImageSource)
|
||||
|
||||
fun onImageShowing(settings: ReaderSettings)
|
||||
|
||||
fun onImageShown()
|
||||
|
||||
fun onProgressChanged(progress: Int)
|
||||
|
||||
fun onConfigChanged()
|
||||
|
||||
fun onTrimMemory()
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,17 @@ class DoublePageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
|
||||
) : PageHolder(
|
||||
owner = owner,
|
||||
binding = binding,
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
) {
|
||||
|
||||
private val isEven: Boolean
|
||||
get() = bindingAdapterPosition and 1 == 0
|
||||
@@ -35,7 +42,7 @@ class DoublePageHolder(
|
||||
.gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
override fun onReady() {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * maxOf(
|
||||
width / sWidth.toFloat(),
|
||||
|
||||
@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class DoublePagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<DoublePageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<DoublePageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = DoublePageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -90,7 +90,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
|
||||
override fun onCreateAdapter() = DoublePagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.RoundedCorner
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.isRtl
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
@@ -17,17 +25,24 @@ class ReversedPageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
|
||||
) : PageHolder(
|
||||
owner = owner,
|
||||
binding = binding,
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
) {
|
||||
|
||||
init {
|
||||
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
|
||||
.gravity = Gravity.START or Gravity.BOTTOM
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
override fun onReady() {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * maxOf(
|
||||
width / sWidth.toFloat(),
|
||||
|
||||
@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class ReversedPagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<ReversedPageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = ReversedPageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ class ReversedReaderFragment : BasePagerReaderFragment() {
|
||||
override fun onCreateAdapter() = ReversedPagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -2,22 +2,28 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.PointF
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.RoundedCorner
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
@@ -27,73 +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 onImageReady(source: ImageSource) {
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
override fun onReady() {
|
||||
binding.ssiv.maxScale = 2f * maxOf(
|
||||
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
|
||||
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
|
||||
@@ -133,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)
|
||||
}
|
||||
@@ -166,6 +122,29 @@ open class PageHolder(
|
||||
scaleBy(0.8f)
|
||||
}
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
protected open fun applyRoundedCorners(insets: WindowInsets) {
|
||||
binding.textViewNumber.updateLayoutParams<FrameLayout.LayoutParams> {
|
||||
val baseMargin = context.resources.getDimensionPixelOffset(R.dimen.margin_small)
|
||||
val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection)
|
||||
val corner = when {
|
||||
absoluteGravity and Gravity.LEFT == Gravity.LEFT -> {
|
||||
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)
|
||||
}
|
||||
|
||||
absoluteGravity and Gravity.RIGHT == Gravity.RIGHT -> {
|
||||
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
setMargins(baseMargin + (corner?.radius ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaleBy(factor: Float) {
|
||||
val ssiv = binding.ssiv
|
||||
val center = ssiv.getCenter() ?: return
|
||||
|
||||
@@ -13,22 +13,27 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class PagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<PageHolder>(
|
||||
loader = loader,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = PageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.vm
|
||||
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
|
||||
sealed class PageState {
|
||||
|
||||
data object Empty : PageState()
|
||||
|
||||
data class Loading(
|
||||
val preview: ImageSource?,
|
||||
val progress: Int,
|
||||
) : PageState()
|
||||
|
||||
data class Loaded(
|
||||
val source: ImageSource,
|
||||
val isConverted: Boolean,
|
||||
) : PageState()
|
||||
|
||||
class Converting() : PageState()
|
||||
|
||||
data class Shown(
|
||||
val source: ImageSource,
|
||||
val isConverted: Boolean,
|
||||
) : PageState()
|
||||
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
) : PageState()
|
||||
|
||||
fun isFinalState(): Boolean = this is Error || this is Shown
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.vm
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.throttle
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
|
||||
class PageViewModel(
|
||||
private val loader: PageLoader,
|
||||
val settingsProducer: ReaderSettings.Producer,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
private val isWebtoon: Boolean,
|
||||
) : DefaultOnImageEventListener {
|
||||
|
||||
private val scope = loader.loaderScope + Dispatchers.Main.immediate
|
||||
private var job: Job? = null
|
||||
private var cachedBounds: Rect? = null
|
||||
|
||||
val state = MutableStateFlow<PageState>(PageState.Empty)
|
||||
|
||||
fun isLoading() = job?.isActive == true
|
||||
|
||||
fun onBind(page: MangaPage) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
doLoad(page, force = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry(page: MangaPage, isFromUser: Boolean) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
val e = (state.value as? PageState.Error)?.error
|
||||
if (e != null && ExceptionResolver.canResolve(e)) {
|
||||
if (isFromUser) {
|
||||
exceptionResolver.resolve(e)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
doLoad(page, force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showErrorDetails(url: String?) {
|
||||
val e = (state.value as? PageState.Error)?.error ?: return
|
||||
exceptionResolver.showErrorDetails(e, url)
|
||||
}
|
||||
|
||||
fun onRecycle() {
|
||||
state.value = PageState.Empty
|
||||
cachedBounds = null
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
override fun onImageLoaded() {
|
||||
state.update { currentState ->
|
||||
if (currentState is PageState.Loaded) {
|
||||
PageState.Shown(currentState.source, currentState.isConverted)
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
|
||||
state.update { currentState ->
|
||||
if (currentState is PageState.Loaded) {
|
||||
val uri = (currentState.source as? ImageSource.Uri)?.uri
|
||||
if (!currentState.isConverted && uri != null && e is IOException) {
|
||||
tryConvert(uri, e)
|
||||
PageState.Converting()
|
||||
} else {
|
||||
PageState.Error(e)
|
||||
}
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryConvert(uri: Uri, e: Exception) {
|
||||
val prevJob = job
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
prevJob?.join()
|
||||
state.value = PageState.Converting()
|
||||
try {
|
||||
val newUri = loader.convertBimap(uri)
|
||||
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(newUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state.value = PageState.Loaded(newUri.toImageSource(cachedBounds), isConverted = true)
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (e2: Throwable) {
|
||||
e2.printStackTrace()
|
||||
e.addSuppressed(e2)
|
||||
state.value = PageState.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
|
||||
state.value = PageState.Loading(null, -1)
|
||||
val previewJob = launch {
|
||||
val preview = loader.loadPreview(data) ?: return@launch
|
||||
state.update {
|
||||
if (it is PageState.Loading) it.copy(preview = preview) else it
|
||||
}
|
||||
}
|
||||
try {
|
||||
val task = loader.loadPageAsync(data, force)
|
||||
val progressObserver = observeProgress(this, task.progressAsFlow())
|
||||
val uri = task.await()
|
||||
progressObserver.cancelAndJoin()
|
||||
previewJob.cancel()
|
||||
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
|
||||
loader.getTrimmedBounds(uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = false)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
state.value = PageState.Error(e)
|
||||
if (e is IOException && !networkState.value) {
|
||||
networkState.awaitForConnection()
|
||||
retry(data, isFromUser = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
|
||||
.throttle(250)
|
||||
.onEach {
|
||||
val progressValue = (100 * it).toInt()
|
||||
state.update { currentState ->
|
||||
if (currentState is PageState.Loading) {
|
||||
currentState.copy(progress = progressValue)
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}.launchIn(scope)
|
||||
|
||||
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
|
||||
val source = ImageSource.uri(this)
|
||||
return if (bounds != null) {
|
||||
source.region(bounds)
|
||||
} else {
|
||||
source
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
class WebtoonAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
) : BaseReaderAdapter<WebtoonHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
readerSettingsProducer: ReaderSettings.Producer,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = WebtoonHolder(
|
||||
@@ -32,7 +32,7 @@ class WebtoonAdapter(
|
||||
false,
|
||||
),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
readerSettingsProducer = readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -1,99 +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 onImageReady(source: ImageSource) {
|
||||
binding.ssiv.setImage(source)
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
override fun onReady() {
|
||||
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
|
||||
with(binding.ssiv) {
|
||||
scrollTo(
|
||||
@@ -107,31 +47,6 @@ class WebtoonHolder(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageShown() {
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
override fun onTrimMemory() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
|
||||
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
fun getScrollY() = binding.ssiv.getScroll()
|
||||
|
||||
fun restoreScroll(scroll: Int) {
|
||||
|
||||
@@ -67,7 +67,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
rv.addItemDecoration(WebtoonGapsDecoration())
|
||||
}
|
||||
}
|
||||
viewModel.readerSettings.observe(viewLifecycleOwner) {
|
||||
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
|
||||
it.applyBackground(binding.root)
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
override fun onCreateAdapter() = WebtoonAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
readerSettingsProducer = viewModel.readerSettingsProducer,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
@@ -87,15 +88,15 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String?) {
|
||||
if (url.isNullOrEmpty()) {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
if (url?.isHttpUrl() == true) {
|
||||
router.openBrowser(
|
||||
url = url,
|
||||
source = viewModel.source,
|
||||
title = viewModel.source.getTitle(requireContext()),
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
|
||||
@@ -114,7 +114,7 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
|
||||
private fun onUserChanged(user: ScrobblerUser?) {
|
||||
if (user == null) {
|
||||
viewBinding.imageViewAvatar.disposeImageRequest()
|
||||
viewBinding.imageViewAvatar.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
|
||||
viewBinding.imageViewAvatar.setImageResource(appcompatR.drawable.abc_ic_menu_overflow_material)
|
||||
return
|
||||
}
|
||||
viewBinding.imageViewAvatar.newImageRequest(this, user.avatar)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user