Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed2232ac2 | ||
|
|
8d9129daaf | ||
|
|
f799606688 | ||
|
|
64adc4f58d | ||
|
|
f6aad3355a | ||
|
|
0badf10a8b | ||
|
|
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 | ||
|
|
a3345d11e7 | ||
|
|
f1ab65ec32 | ||
|
|
6282d25d3d | ||
|
|
47c3f9ff3b | ||
|
|
5cd2f1b9e6 | ||
|
|
5d9b18ec11 | ||
|
|
5aec1f644d | ||
|
|
aee092f0b3 | ||
|
|
9cc1cdac62 | ||
|
|
1e73739ddb | ||
|
|
d1d7cc9adf | ||
|
|
6a0ad7f79b | ||
|
|
f7c70577ae | ||
|
|
937ed798cf | ||
|
|
8da4f0e180 | ||
|
|
170d12f143 | ||
|
|
0fe3409577 | ||
|
|
36e431a1ca | ||
|
|
f30ebda851 | ||
|
|
0f021a2d6e | ||
|
|
f816c8ca6e | ||
|
|
fe0c4605f7 | ||
|
|
196bbff103 | ||
|
|
80b26e62e9 | ||
|
|
f877637fd2 | ||
|
|
5037b4ef84 | ||
|
|
11b7696d31 | ||
|
|
4ad361dab8 | ||
|
|
1b88857e4d | ||
|
|
7823bff063 | ||
|
|
947de6c7c9 | ||
|
|
f689bf0cf7 | ||
|
|
b3028258ca | ||
|
|
2c8476cabd | ||
|
|
5373e58807 | ||
|
|
4fdb781622 | ||
|
|
0981ba771a | ||
|
|
7cec7f5359 | ||
|
|
8e55739685 | ||
|
|
d4a2d97071 | ||
|
|
d51790811a | ||
|
|
8d7f44d2da | ||
|
|
930d4dfd83 | ||
|
|
290cb652ee |
@@ -19,15 +19,16 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1003
|
||||
versionName = '8.0-b3'
|
||||
versionCode = 1014
|
||||
versionName = '8.1.8'
|
||||
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"
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
@@ -14,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
@@ -24,8 +26,8 @@ class AlternativesUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
|
||||
val sources = getSources(manga.source, throughDisabledSources)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
@@ -39,12 +41,14 @@ class AlternativesUseCase @Inject constructor(
|
||||
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||
}
|
||||
}.getOrNull()
|
||||
list?.forEach {
|
||||
launch {
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
send(details)
|
||||
list?.forEach { m ->
|
||||
if (m.id != manga.id) {
|
||||
launch {
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(m.source).getDetails(m)
|
||||
}.getOrDefault(m)
|
||||
send(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,19 +56,23 @@ class AlternativesUseCase @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
|
||||
result.addAll(sourcesRepository.getEnabledSources())
|
||||
result.sortByDescending { it.priority(ref) }
|
||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||
return result
|
||||
}
|
||||
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
|
||||
sourcesRepository.getDisabledSources()
|
||||
} else {
|
||||
sourcesRepository.getEnabledSources()
|
||||
}.sortedByDescending { it.priority(ref) }
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
var res = 0
|
||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||
if (locale == ref.locale) res += 2
|
||||
if (contentType == ref.contentType) res++
|
||||
if (locale == ref.locale) {
|
||||
res += 4
|
||||
} else if (locale.toLocale() == Locale.getDefault()) {
|
||||
res += 2
|
||||
}
|
||||
if (contentType == ref.contentType) {
|
||||
res++
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.concat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -35,7 +36,8 @@ class AutoFixUseCase @Inject constructor(
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
val replacement = alternativesUseCase(seed)
|
||||
val replacement = alternativesUseCase(seed, throughDisabledSources = false)
|
||||
.concat(alternativesUseCase(seed, throughDisabledSources = true))
|
||||
.filter { it.isHealthy() }
|
||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||
if (best == null || best < candidate) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -15,12 +16,15 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
@@ -30,6 +34,7 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
ListStateHolderListener,
|
||||
OnListItemClickListener<MangaAlternativeModel> {
|
||||
|
||||
@Inject
|
||||
@@ -49,15 +54,15 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
||||
with(viewBinding.recyclerView) {
|
||||
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||
adapter = listAdapter
|
||||
}
|
||||
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
viewModel.content.observe(this, listAdapter)
|
||||
viewModel.list.observe(this, listAdapter)
|
||||
viewModel.onMigrated.observeEvent(this) {
|
||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||
router.openDetails(it)
|
||||
@@ -65,6 +70,24 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(
|
||||
v: View,
|
||||
insets: WindowInsetsCompat
|
||||
): WindowInsetsCompat {
|
||||
val barsInsets = insets.systemBarsInsets
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
bottom = barsInsets.bottom,
|
||||
)
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
top = barsInsets.top,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
||||
@@ -73,6 +96,12 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = viewModel.retry()
|
||||
|
||||
override fun onEmptyActionClick() = Unit
|
||||
|
||||
override fun onFooterButtonClick() = viewModel.continueSearch()
|
||||
|
||||
private fun confirmMigration(target: Manga) {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
setIcon(R.drawable.ic_replace)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEmpty
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||
@@ -18,16 +22,19 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.append
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -41,39 +48,62 @@ class AlternativesViewModel @Inject constructor(
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||
|
||||
val onMigrated = MutableEventFlow<Manga>()
|
||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||
private var includeDisabledSources = MutableStateFlow(false)
|
||||
private val results = MutableStateFlow<List<MangaAlternativeModel>>(emptyList())
|
||||
|
||||
private var migrationJob: Job? = null
|
||||
private var searchJob: Job? = null
|
||||
|
||||
private val mangaDetails = suspendLazy {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}
|
||||
|
||||
val onMigrated = MutableEventFlow<Manga>()
|
||||
|
||||
val list: StateFlow<List<ListModel>> = combine(
|
||||
results,
|
||||
isLoading,
|
||||
includeDisabledSources,
|
||||
) { list, loading, includeDisabled ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
when {
|
||||
loading -> LoadingState
|
||||
else -> EmptyState(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_search_holder_secondary,
|
||||
actionStringRes = 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
loading -> list + LoadingFooter()
|
||||
includeDisabled -> list
|
||||
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val ref = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}.getOrDefault(manga)
|
||||
val refCount = ref.chaptersCount()
|
||||
alternativesUseCase(ref)
|
||||
.map {
|
||||
MangaAlternativeModel(
|
||||
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
||||
}.onEmpty {
|
||||
emit(
|
||||
listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_search_holder_secondary,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.collect {
|
||||
content.value = it
|
||||
}
|
||||
content.value = content.value.filterNot { it is LoadingFooter }
|
||||
doSearch(throughDisabledSources = false)
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
searchJob?.cancel()
|
||||
results.value = emptyList()
|
||||
includeDisabledSources.value = false
|
||||
doSearch(throughDisabledSources = false)
|
||||
}
|
||||
|
||||
fun continueSearch() {
|
||||
if (includeDisabledSources.value) {
|
||||
return
|
||||
}
|
||||
val prevJob = searchJob
|
||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||
includeDisabledSources.value = true
|
||||
prevJob?.join()
|
||||
doSearch(throughDisabledSources = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +116,21 @@ class AlternativesViewModel @Inject constructor(
|
||||
onMigrated.call(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doSearch(throughDisabledSources: Boolean) {
|
||||
val prevJob = searchJob
|
||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val ref = mangaDetails.getOrDefault(manga)
|
||||
val refCount = ref.chaptersCount()
|
||||
alternativesUseCase.invoke(ref, throughDisabledSources)
|
||||
.collect {
|
||||
val model = MangaAlternativeModel(
|
||||
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
results.append(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
@@ -10,6 +9,7 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil3.ImageLoader
|
||||
@@ -26,10 +26,11 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||
@@ -85,7 +86,6 @@ class AllBookmarksFragment :
|
||||
)
|
||||
val spanSizeLookup = SpanSizeLookup()
|
||||
with(binding.recyclerView) {
|
||||
consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
|
||||
setHasFixedSize(true)
|
||||
val spanResolver = GridSpanResolver(resources)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
@@ -107,6 +107,18 @@ class AllBookmarksFragment :
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.systemBarsInsets
|
||||
val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
|
||||
viewBinding?.recyclerView?.setPadding(
|
||||
barsInsets.left + basePadding,
|
||||
barsInsets.top + basePadding,
|
||||
barsInsets.right + basePadding,
|
||||
barsInsets.bottom + basePadding,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
bookmarksAdapter = null
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
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
|
||||
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
|
||||
@Inject
|
||||
lateinit var proxyProvider: ProxyProvider
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||
return
|
||||
}
|
||||
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
|
||||
): WindowInsetsCompat {
|
||||
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
|
||||
val barsInsets = insets.getInsets(type)
|
||||
viewBinding.webView.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
bottom = barsInsets.bottom,
|
||||
)
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
top = barsInsets.top,
|
||||
)
|
||||
return insets.consumeAll(type)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (hasViewBinding()) {
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
this.title = title
|
||||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,45 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
class BrowserActivity : BaseBrowserActivity() {
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
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.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(
|
||||
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
||||
url,
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(
|
||||
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
||||
url,
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,35 +65,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (hasViewBinding()) {
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
this.title = title
|
||||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
||||
open class BrowserClient(
|
||||
private val callback: BrowserCallback
|
||||
) : WebViewClient() {
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
|
||||
*/
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
@@ -16,7 +22,7 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient(
|
||||
callback.onLoadingStateChanged(isLoading = true)
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||
override fun onPageCommitVisible(view: WebView, url: String) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ package org.koitharu.kotatsu.browser.cloudflare
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -18,21 +17,20 @@ import kotlinx.coroutines.yield
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.browser.BaseBrowserActivity
|
||||
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.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
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
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
||||
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
|
||||
private var pendingResult = RESULT_CANCELED
|
||||
|
||||
@@ -40,43 +38,27 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private lateinit var cfClient: CloudFlareClient
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
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(cookieJar, this, url)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
|
||||
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
} catch (e: Exception) {
|
||||
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching {
|
||||
viewBinding.webView
|
||||
}.onSuccess {
|
||||
it.stopLoading()
|
||||
it.destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
@@ -99,21 +81,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(pendingResult)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onPageLoaded() {
|
||||
viewBinding.progressBar.isInvisible = true
|
||||
}
|
||||
@@ -131,18 +105,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback?.onHistoryChanged()
|
||||
}
|
||||
|
||||
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,8 +4,6 @@ import org.koitharu.kotatsu.browser.BrowserCallback
|
||||
|
||||
interface CloudFlareCallback : BrowserCallback {
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
||||
|
||||
fun onPageLoaded()
|
||||
|
||||
@@ -22,7 +22,7 @@ class CloudFlareClient(
|
||||
checkClearance()
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||
override fun onPageCommitVisible(view: WebView, url: String) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ interface AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCoil(
|
||||
@ApplicationContext context: Context,
|
||||
@LocalizedAppContext context: Context,
|
||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
||||
|
||||
@@ -8,11 +9,9 @@ class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
private val lifetime: Long,
|
||||
private val timeUnit: TimeUnit,
|
||||
) : Iterable<CacheKey> {
|
||||
) {
|
||||
|
||||
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||
|
||||
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
||||
private val cache = SynchronizedSieveCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||
|
||||
operator fun get(key: CacheKey): T? {
|
||||
val value = cache[key] ?: return null
|
||||
@@ -23,7 +22,8 @@ class ExpiringLruCache<T>(
|
||||
}
|
||||
|
||||
operator fun set(key: CacheKey, value: T) {
|
||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||
val value = ExpiringValue(value, lifetime, timeUnit)
|
||||
cache.put(key, value)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
@@ -37,4 +37,8 @@ class ExpiringLruCache<T>(
|
||||
fun remove(key: CacheKey) {
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
fun removeAll(source: MangaSource) {
|
||||
cache.removeIf { key, _ -> key.source == source }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,7 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
||||
}
|
||||
|
||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||
cache.forEach { key ->
|
||||
if (key.source == source) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
cache.removeAll(source)
|
||||
}
|
||||
|
||||
data class Key(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
class SnackbarErrorObserver(
|
||||
@@ -24,8 +25,9 @@ class SnackbarErrorObserver(
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
if (activity is BottomNavOwner) {
|
||||
snackbar.anchorView = activity.bottomNav
|
||||
when (activity) {
|
||||
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
|
||||
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
|
||||
}
|
||||
if (canResolve(value)) {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
||||
|
||||
@@ -43,6 +43,9 @@ class AppUpdateRepository @Inject constructor(
|
||||
append("/releases?page=1&per_page=10")
|
||||
}
|
||||
|
||||
val isUpdateAvailable: Boolean
|
||||
get() = availableUpdate.value != null
|
||||
|
||||
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
||||
|
||||
suspend fun getAvailableVersions(): List<AppVersion> {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.text.SpannableStringBuilder
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableObjectIntMap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.strikeThrough
|
||||
@@ -125,7 +126,8 @@ val Manga.isBroken: Boolean
|
||||
get() = source == UnknownMangaSource
|
||||
|
||||
val Manga.appUrl: Uri
|
||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||
get() = "https://kotatsu.app/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendQueryParameter("source", source.name)
|
||||
.appendQueryParameter("name", title)
|
||||
.appendQueryParameter("url", url)
|
||||
@@ -147,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(),
|
||||
) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -27,9 +29,13 @@ 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
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isBroken
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
|
||||
@@ -43,6 +49,8 @@ import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
@@ -72,6 +80,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||
@@ -96,6 +105,8 @@ import org.koitharu.kotatsu.stats.ui.StatsActivity
|
||||
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 androidx.appcompat.R as appcompatR
|
||||
|
||||
class AppRouter private constructor(
|
||||
private val activity: FragmentActivity?,
|
||||
@@ -170,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) },
|
||||
)
|
||||
}
|
||||
@@ -353,6 +365,7 @@ class AppRouter private constructor(
|
||||
|
||||
fun showTagDialog(tag: MangaTag) {
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setIcon(R.drawable.ic_tag)
|
||||
setTitle(tag.title)
|
||||
setItems(
|
||||
arrayOf(
|
||||
@@ -372,6 +385,7 @@ class AppRouter private constructor(
|
||||
|
||||
fun showAuthorDialog(author: String, source: MangaSource) {
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setIcon(R.drawable.ic_user)
|
||||
setTitle(author)
|
||||
setItems(
|
||||
arrayOf(
|
||||
@@ -389,6 +403,37 @@ class AppRouter private constructor(
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun showShareDialog(manga: Manga) {
|
||||
if (manga.isBroken) {
|
||||
return
|
||||
}
|
||||
if (manga.isLocal) {
|
||||
manga.url.toUri().toFileOrNull()?.let {
|
||||
shareFile(it)
|
||||
}
|
||||
return
|
||||
}
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable))
|
||||
setTitle(R.string.share)
|
||||
setItems(
|
||||
arrayOf(
|
||||
context.getString(R.string.link_to_manga_in_app),
|
||||
context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)),
|
||||
),
|
||||
) { _, which ->
|
||||
val link = when (which) {
|
||||
0 -> manga.appUrl.toString()
|
||||
1 -> manga.publicUrl
|
||||
else -> return@setItems
|
||||
}
|
||||
shareLink(link, manga.title)
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun showErrorDialog(error: Throwable, url: String? = null) {
|
||||
ErrorDetailsDialog().withArgs(2) {
|
||||
putSerializable(KEY_ERROR, error)
|
||||
@@ -544,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 {
|
||||
@@ -563,6 +611,25 @@ class AppRouter private constructor(
|
||||
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
||||
}
|
||||
|
||||
private fun shareLink(link: String, title: String) {
|
||||
val context = contextOrNull() ?: return
|
||||
ShareCompat.IntentBuilder(context)
|
||||
.setText(link)
|
||||
.setType(TYPE_TEXT)
|
||||
.setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12)))
|
||||
.startChooser()
|
||||
}
|
||||
|
||||
private fun shareFile(file: File) { // TODO directory sharing support
|
||||
val context = contextOrNull() ?: return
|
||||
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||
.setType(TYPE_CBZ)
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
|
||||
intentBuilder.addStream(uri)
|
||||
intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name))
|
||||
intentBuilder.startChooser()
|
||||
}
|
||||
|
||||
@UiContext
|
||||
private fun contextOrNull(): Context? = activity ?: fragment?.context
|
||||
|
||||
@@ -687,6 +754,12 @@ class AppRouter private constructor(
|
||||
.putExtra(KEY_SOURCE, source.name)
|
||||
}
|
||||
|
||||
fun isShareSupported(manga: Manga): Boolean = when {
|
||||
manga.isBroken -> false
|
||||
manga.isLocal -> manga.url.toUri().toFileOrNull() != null
|
||||
else -> true
|
||||
}
|
||||
|
||||
const val KEY_DATA = "data"
|
||||
const val KEY_ENTRIES = "entries"
|
||||
const val KEY_ERROR = "error"
|
||||
@@ -700,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"
|
||||
@@ -724,6 +798,10 @@ class AppRouter private constructor(
|
||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
|
||||
|
||||
private const val TYPE_TEXT = "text/plain"
|
||||
private const val TYPE_IMAGE = "image/*"
|
||||
private const val TYPE_CBZ = "application/x-cbz"
|
||||
|
||||
private fun Class<out Fragment>.fragmentTag() = name // TODO
|
||||
|
||||
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.ProxySelector
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
|
||||
class AppProxySelector(
|
||||
private val settings: AppSettings,
|
||||
) : ProxySelector() {
|
||||
|
||||
init {
|
||||
setDefault(this)
|
||||
}
|
||||
|
||||
private var cachedProxy: Proxy? = null
|
||||
|
||||
override fun select(uri: URI?): List<Proxy> {
|
||||
return listOf(getProxy())
|
||||
}
|
||||
|
||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
||||
ioe?.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private fun getProxy(): Proxy {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
if (type == Proxy.Type.DIRECT) {
|
||||
return Proxy.NO_PROXY
|
||||
}
|
||||
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
|
||||
throw ProxyConfigException()
|
||||
}
|
||||
cachedProxy?.let {
|
||||
val addr = it.address() as? InetSocketAddress
|
||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||
return it
|
||||
}
|
||||
}
|
||||
val proxy = Proxy(type, InetSocketAddress(address, port))
|
||||
cachedProxy = proxy
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
||||
intercept(chain)
|
||||
}.getOrElse { e ->
|
||||
if (e is IOException) {
|
||||
if (e is IOException || e is Error) {
|
||||
throw e
|
||||
} else {
|
||||
// only IOException can be safely thrown from an Interceptor
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
@@ -62,14 +63,15 @@ interface NetworkModule {
|
||||
cache: Cache,
|
||||
cookieJar: CookieJar,
|
||||
settings: AppSettings,
|
||||
proxyProvider: ProxyProvider,
|
||||
): OkHttpClient = OkHttpClient.Builder().apply {
|
||||
assertNotInMainThread()
|
||||
connectTimeout(20, TimeUnit.SECONDS)
|
||||
readTimeout(60, TimeUnit.SECONDS)
|
||||
writeTimeout(20, TimeUnit.SECONDS)
|
||||
cookieJar(cookieJar)
|
||||
proxySelector(AppProxySelector(settings))
|
||||
proxyAuthenticator(ProxyAuthenticator(settings))
|
||||
proxySelector(proxyProvider.selector)
|
||||
proxyAuthenticator(proxyProvider.authenticator)
|
||||
dns(DoHManager(cache, settings))
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
disableCertificateVerification()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.Proxy
|
||||
|
||||
class ProxyAuthenticator(
|
||||
private val settings: AppSettings,
|
||||
) : Authenticator, java.net.Authenticator() {
|
||||
|
||||
init {
|
||||
setDefault(this)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (!isProxyEnabled()) {
|
||||
return null
|
||||
}
|
||||
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
||||
return null
|
||||
}
|
||||
val login = settings.proxyLogin ?: return null
|
||||
val password = settings.proxyPassword ?: return null
|
||||
val credential = Credentials.basic(login, password)
|
||||
return response.request.newBuilder()
|
||||
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||
if (!isProxyEnabled()) {
|
||||
return null
|
||||
}
|
||||
val login = settings.proxyLogin ?: return null
|
||||
val password = settings.proxyPassword ?: return null
|
||||
return PasswordAuthentication(login, password.toCharArray())
|
||||
}
|
||||
|
||||
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package org.koitharu.kotatsu.core.network.proxy
|
||||
|
||||
import androidx.webkit.ProxyConfig
|
||||
import androidx.webkit.ProxyController
|
||||
import androidx.webkit.WebViewFeature
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.Proxy
|
||||
import java.net.ProxySelector
|
||||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import java.net.Authenticator as JavaAuthenticator
|
||||
|
||||
@Singleton
|
||||
class ProxyProvider @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private var cachedProxy: Proxy? = null
|
||||
|
||||
val selector = object : ProxySelector() {
|
||||
override fun select(uri: URI?): List<Proxy> {
|
||||
return listOf(getProxy())
|
||||
}
|
||||
|
||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) {
|
||||
ioe?.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
val authenticator = ProxyAuthenticator()
|
||||
|
||||
init {
|
||||
ProxySelector.setDefault(selector)
|
||||
JavaAuthenticator.setDefault(authenticator)
|
||||
}
|
||||
|
||||
suspend fun applyWebViewConfig() {
|
||||
val isProxyEnabled = isProxyEnabled()
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||
if (isProxyEnabled) {
|
||||
throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize
|
||||
}
|
||||
} else {
|
||||
val controller = ProxyController.getInstance()
|
||||
if (settings.proxyType == Proxy.Type.DIRECT) {
|
||||
suspendCoroutine { cont ->
|
||||
controller.clearProxyOverride(
|
||||
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
|
||||
) {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val url = buildString {
|
||||
when (settings.proxyType) {
|
||||
Proxy.Type.DIRECT -> Unit
|
||||
Proxy.Type.HTTP -> append("http")
|
||||
Proxy.Type.SOCKS -> append("socks")
|
||||
}
|
||||
append("://")
|
||||
append(settings.proxyAddress)
|
||||
append(':')
|
||||
append(settings.proxyPort)
|
||||
}
|
||||
if (settings.proxyType == Proxy.Type.SOCKS) {
|
||||
System.setProperty("java.net.socks.username", settings.proxyLogin);
|
||||
System.setProperty("java.net.socks.password", settings.proxyPassword);
|
||||
}
|
||||
val proxyConfig = ProxyConfig.Builder()
|
||||
.addProxyRule(url)
|
||||
.build()
|
||||
suspendCoroutine { cont ->
|
||||
controller.setProxyOverride(
|
||||
proxyConfig,
|
||||
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
|
||||
) {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
||||
|
||||
private fun getProxy(): Proxy {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
if (type == Proxy.Type.DIRECT) {
|
||||
return Proxy.NO_PROXY
|
||||
}
|
||||
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
|
||||
throw ProxyConfigException()
|
||||
}
|
||||
cachedProxy?.let {
|
||||
val addr = it.address() as? InetSocketAddress
|
||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||
return it
|
||||
}
|
||||
}
|
||||
val proxy = Proxy(type, InetSocketAddress(address, port))
|
||||
cachedProxy = proxy
|
||||
return proxy
|
||||
}
|
||||
|
||||
inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() {
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (!isProxyEnabled()) {
|
||||
return null
|
||||
}
|
||||
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
||||
return null
|
||||
}
|
||||
val login = settings.proxyLogin ?: return null
|
||||
val password = settings.proxyPassword ?: return null
|
||||
val credential = Credentials.basic(login, password)
|
||||
return response.request.newBuilder()
|
||||
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
||||
.build()
|
||||
}
|
||||
|
||||
public override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||
if (!isProxyEnabled()) {
|
||||
return null
|
||||
}
|
||||
val login = settings.proxyLogin ?: return null
|
||||
val password = settings.proxyPassword ?: return null
|
||||
return PasswordAuthentication(login, password.toCharArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ContinuationResumeWebViewClient(
|
||||
private val continuation: Continuation<Unit>,
|
||||
) : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class AppShortcutManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat {
|
||||
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat = withContext(Dispatchers.Default) {
|
||||
val icon = runCatchingCancellable {
|
||||
coil.execute(
|
||||
ImageRequest.Builder(context)
|
||||
@@ -149,7 +149,7 @@ class AppShortcutManager @Inject constructor(
|
||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||
)
|
||||
mangaRepository.storeManga(manga)
|
||||
return ShortcutInfoCompat.Builder(context, manga.id.toString())
|
||||
ShortcutInfoCompat.Builder(context, manga.id.toString())
|
||||
.setShortLabel(manga.title)
|
||||
.setLongLabel(manga.title)
|
||||
.setIcon(icon)
|
||||
@@ -159,8 +159,7 @@ class AppShortcutManager @Inject constructor(
|
||||
.mangaId(manga.id)
|
||||
.build()
|
||||
.intent,
|
||||
)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {
|
||||
|
||||
@@ -9,7 +9,10 @@ import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
@@ -18,6 +21,7 @@ import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.webview.ContinuationResumeWebViewClient
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
@@ -33,6 +37,7 @@ import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.map
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
@@ -48,13 +53,28 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
||||
private val jsMutex = Mutex()
|
||||
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
|
||||
|
||||
@Deprecated("Provide a base url")
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
|
||||
|
||||
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
|
||||
jsMutex.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
|
||||
@@ -58,13 +57,7 @@ class ParserMangaRepository(
|
||||
val domains: Array<out String>
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
parser.intercept(chain)
|
||||
} else {
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain)
|
||||
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
|
||||
return mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
@@ -96,7 +89,7 @@ class ParserMangaRepository(
|
||||
parser.getDetails(manga)
|
||||
}
|
||||
|
||||
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
||||
fun getAuthProvider(): MangaParserAuthProvider? = parser.authorizationProvider
|
||||
|
||||
fun getRequestHeaders() = parser.getRequestHeaders()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -569,7 +575,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
fun getAllValues(): Map<String, *> = prefs.all
|
||||
|
||||
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
|
||||
fun upsertAll(m: Map<String, *>) = prefs.edit {
|
||||
clear()
|
||||
putAll(m)
|
||||
}
|
||||
|
||||
private fun isBackgroundNetworkRestricted(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
@@ -655,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"
|
||||
@@ -725,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"
|
||||
@@ -741,6 +752,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
||||
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
|
||||
const val KEY_STORAGE_USAGE = "storage_usage"
|
||||
const val KEY_WEBVIEW_CLEAR = "webview_clear"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -27,10 +29,12 @@ 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 androidx.appcompat.R as appcompatR
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
OnApplyWindowInsetsListener,
|
||||
ScreenshotPolicyHelper.ContentContainer {
|
||||
|
||||
private var isAmoledTheme = false
|
||||
@@ -78,16 +82,10 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(layoutResID: Int) {
|
||||
super.setContentView(layoutResID)
|
||||
setupToolbar()
|
||||
}
|
||||
override fun setContentView(layoutResID: Int) = throw UnsupportedOperationException()
|
||||
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View?) {
|
||||
super.setContentView(view)
|
||||
setupToolbar()
|
||||
}
|
||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getContext() = this
|
||||
|
||||
@@ -96,10 +94,20 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
|
||||
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
|
||||
toolbar?.let(this::setSupportActionBar)
|
||||
}
|
||||
|
||||
protected fun setDisplayHomeAsUp(isEnabled: Boolean, showUpAsClose: Boolean) {
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(isEnabled)
|
||||
if (showUpAsClose) {
|
||||
setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
val fm = supportFragmentManager
|
||||
if (fm.isStateSaved) {
|
||||
@@ -125,10 +133,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
|
||||
}
|
||||
|
||||
protected fun isDarkAmoledTheme(): Boolean {
|
||||
val uiMode = resources.configuration.uiMode
|
||||
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
OnApplyWindowInsetsListener,
|
||||
Fragment(),
|
||||
ExceptionResolver.Host {
|
||||
|
||||
@@ -42,6 +45,7 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||
onViewBindingCreated(requireViewBinding(), savedInstanceState)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
@@ -12,13 +14,18 @@ import androidx.preference.get
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.container
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
@@ -26,6 +33,7 @@ import com.google.android.material.R as materialR
|
||||
@AndroidEntryPoint
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
OnApplyWindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
|
||||
@@ -46,10 +54,23 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||
val themedContext = (view.parentView ?: view).context
|
||||
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
|
||||
listView.clipToPadding = false
|
||||
listView.consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.systemBarsInsets
|
||||
val isTablet = !resources.getBoolean(R.bool.is_tablet)
|
||||
val isMaster = container?.id == R.id.container_master
|
||||
listView.setPaddingRelative(
|
||||
if (isTablet && !isMaster) 0 else barsInsets.start(v),
|
||||
0,
|
||||
if (isTablet && isMaster) 0 else barsInsets.end(v),
|
||||
barsInsets.bottom,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets
|
||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||
@@ -26,7 +30,7 @@ abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fr
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayHomeAsUp(true, false)
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
fm.commit {
|
||||
@@ -36,5 +40,15 @@ abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fr
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = bars.left,
|
||||
right = bars.right,
|
||||
top = bars.top,
|
||||
)
|
||||
return insets.consumeSystemBarsInsets(top = true)
|
||||
}
|
||||
|
||||
protected open fun getFragmentExtras(): Bundle? = intent.extras
|
||||
}
|
||||
|
||||
@@ -2,26 +2,34 @@ package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.htmlEncode
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
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
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
@AndroidEntryPoint
|
||||
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>(), View.OnClickListener {
|
||||
|
||||
private lateinit var exception: Throwable
|
||||
|
||||
@Inject
|
||||
lateinit var appUpdateRepository: AppUpdateRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val args = requireArguments()
|
||||
@@ -34,31 +42,50 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
with(binding.textViewMessage) {
|
||||
movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
text = context.getString(
|
||||
R.string.manga_error_description_pattern,
|
||||
exception.message?.htmlEncode().orEmpty(),
|
||||
arguments?.getString(AppRouter.KEY_URL) ?: exception.getCauseUrl(),
|
||||
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
binding.buttonBrowser.setOnClickListener(this)
|
||||
binding.textViewSummary.text = exception.message
|
||||
val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true
|
||||
binding.buttonBrowser.isVisible = isUrlAvailable
|
||||
binding.textViewBrowser.isVisible = isUrlAvailable
|
||||
binding.textViewDescription.setTextAndVisible(
|
||||
if (appUpdateRepository.isUpdateAvailable) {
|
||||
R.string.error_disclaimer_app_outdated
|
||||
} else if (exception.isReportable()) {
|
||||
R.string.error_disclaimer_report
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
val builder = super.onBuildDialog(builder)
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setTitle(R.string.error_occurred)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setTitle(R.string.error_details)
|
||||
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
|
||||
context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString())
|
||||
}
|
||||
if (exception.isReportable()) {
|
||||
builder.setPositiveButton(R.string.report) { _, _ ->
|
||||
if (appUpdateRepository.isUpdateAvailable) {
|
||||
builder.setPositiveButton(R.string.update) { _, _ ->
|
||||
router.openAppUpdate()
|
||||
dismiss()
|
||||
}
|
||||
} else if (exception.isReportable()) {
|
||||
builder.setPositiveButton(R.string.report) { _, _ ->
|
||||
exception.report(silent = true)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
router.openBrowser(
|
||||
url = exception.getCauseUrl() ?: return,
|
||||
source = null,
|
||||
title = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,14 @@ import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.ancestors
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
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
|
||||
@@ -131,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)
|
||||
@@ -245,8 +247,8 @@ class FastScroller @JvmOverloads constructor(
|
||||
*/
|
||||
fun setLayoutParams(viewGroup: ViewGroup) {
|
||||
val recyclerViewId = recyclerView?.id ?: NO_ID
|
||||
val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
|
||||
val offsetTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
|
||||
val offsetBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
|
||||
|
||||
require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
|
||||
|
||||
@@ -263,31 +265,43 @@ class FastScroller @JvmOverloads constructor(
|
||||
applyTo(viewGroup)
|
||||
}
|
||||
|
||||
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
|
||||
updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
height = 0
|
||||
setMargins(offset, marginTop, offset, marginBottom)
|
||||
marginStart = offset
|
||||
marginEnd = offset
|
||||
topMargin = offsetTop
|
||||
bottomMargin = offsetBottom
|
||||
}
|
||||
}
|
||||
|
||||
is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply {
|
||||
is CoordinatorLayout -> updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
height = LayoutParams.MATCH_PARENT
|
||||
anchorGravity = GravityCompat.END
|
||||
anchorId = recyclerViewId
|
||||
setMargins(offset, marginTop, offset, marginBottom)
|
||||
marginStart = offset
|
||||
marginEnd = offset
|
||||
topMargin = offsetTop
|
||||
bottomMargin = offsetBottom
|
||||
}
|
||||
|
||||
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
|
||||
is FrameLayout -> updateLayoutParams<FrameLayout.LayoutParams> {
|
||||
height = LayoutParams.MATCH_PARENT
|
||||
gravity = GravityCompat.END
|
||||
setMargins(offset, marginTop, offset, marginBottom)
|
||||
marginStart = offset
|
||||
marginEnd = offset
|
||||
topMargin = offsetTop
|
||||
bottomMargin = offsetBottom
|
||||
}
|
||||
|
||||
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
|
||||
is RelativeLayout -> updateLayoutParams<RelativeLayout.LayoutParams> {
|
||||
height = 0
|
||||
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
|
||||
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
|
||||
addRule(RelativeLayout.ALIGN_END, recyclerViewId)
|
||||
setMargins(offset, marginTop, offset, marginBottom)
|
||||
marginStart = offset
|
||||
marginEnd = offset
|
||||
topMargin = offsetTop
|
||||
bottomMargin = offsetBottom
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -29,7 +31,9 @@ import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), ExceptionResolver.Host {
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||
OnApplyWindowInsetsListener,
|
||||
ExceptionResolver.Host {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
@@ -74,6 +78,7 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), E
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||
val binding = requireViewBinding()
|
||||
if (actionModeDelegate == null) {
|
||||
actionModeDelegate = (activity as? BaseActivity<*>)?.actionModeDelegate
|
||||
|
||||
@@ -21,19 +21,12 @@ class BottomSheetCollapseCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
|
||||
@SuppressLint("SwitchIntDef")
|
||||
override fun onStateChanged(view: View, state: Int) {
|
||||
when (state) {
|
||||
STATE_EXPANDED,
|
||||
STATE_HALF_EXPANDED -> isEnabled = true
|
||||
|
||||
STATE_COLLAPSED,
|
||||
STATE_HIDDEN -> isEnabled = false
|
||||
}
|
||||
}
|
||||
override fun onStateChanged(view: View, state: Int) = onStateChanged(state)
|
||||
|
||||
override fun onSlide(p0: View, p1: Float) = Unit
|
||||
},
|
||||
)
|
||||
onStateChanged(behavior.state)
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() = behavior.handleBackInvoked()
|
||||
@@ -43,4 +36,14 @@ class BottomSheetCollapseCallback(
|
||||
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
|
||||
|
||||
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
|
||||
|
||||
private fun onStateChanged(state: Int) {
|
||||
when (state) {
|
||||
STATE_EXPANDED,
|
||||
STATE_HALF_EXPANDED -> isEnabled = true
|
||||
|
||||
STATE_COLLAPSED,
|
||||
STATE_HIDDEN -> isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.GravityInt
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeRelative
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
|
||||
class InsetsToMarginsListener(
|
||||
@GravityInt
|
||||
private val sides: Int,
|
||||
private val baseMargins: Insets,
|
||||
) : OnApplyWindowInsetsListener {
|
||||
|
||||
private val insetType = WindowInsetsCompat.Type.systemBars()
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(insetType)
|
||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
if (sides and Gravity.START == Gravity.START) {
|
||||
marginStart = barsInsets.start(v) + baseMargins.start(v)
|
||||
}
|
||||
if (sides and Gravity.TOP == Gravity.TOP) {
|
||||
topMargin = barsInsets.top + baseMargins.top
|
||||
}
|
||||
if (sides and Gravity.END == Gravity.END) {
|
||||
marginEnd = barsInsets.end(v) + baseMargins.end(v)
|
||||
}
|
||||
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
|
||||
bottomMargin = barsInsets.bottom + baseMargins.bottom
|
||||
}
|
||||
}
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(
|
||||
insetType,
|
||||
barsInsets.consumeRelative(
|
||||
v,
|
||||
start = sides and Gravity.START == Gravity.START,
|
||||
top = sides and Gravity.TOP == Gravity.TOP,
|
||||
end = sides and Gravity.END == Gravity.END,
|
||||
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
|
||||
),
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.annotation.GravityInt
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeRelative
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
|
||||
class InsetsToPaddingListener(
|
||||
@GravityInt
|
||||
private val sides: Int,
|
||||
private val basePaddings: Insets,
|
||||
) : OnApplyWindowInsetsListener {
|
||||
|
||||
private val insetType = WindowInsetsCompat.Type.systemBars()
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(insetType)
|
||||
v.setPaddingRelative(
|
||||
/* start = */
|
||||
if (sides and Gravity.START == Gravity.START) {
|
||||
barsInsets.start(v) + basePaddings.start(v)
|
||||
} else {
|
||||
v.paddingStart
|
||||
},
|
||||
/* top = */
|
||||
if (sides and Gravity.TOP == Gravity.TOP) {
|
||||
barsInsets.top + basePaddings.top
|
||||
} else {
|
||||
v.paddingTop
|
||||
},
|
||||
/* end = */
|
||||
if (sides and Gravity.END == Gravity.END) {
|
||||
barsInsets.end(v) + basePaddings.end(v)
|
||||
} else {
|
||||
v.paddingEnd
|
||||
},
|
||||
/* bottom = */
|
||||
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
|
||||
barsInsets.bottom + basePaddings.bottom
|
||||
} else {
|
||||
v.paddingBottom
|
||||
},
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(
|
||||
insetType,
|
||||
barsInsets.consumeRelative(
|
||||
v,
|
||||
start = sides and Gravity.START == Gravity.START,
|
||||
top = sides and Gravity.TOP == Gravity.TOP,
|
||||
end = sides and Gravity.END == Gravity.END,
|
||||
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
|
||||
),
|
||||
).build()
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,21 @@ import android.view.View
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
|
||||
|
||||
class ReversibleActionObserver(
|
||||
private val snackbarHost: View,
|
||||
private val snackbarAnchor: View? = null,
|
||||
) : FlowCollector<ReversibleAction> {
|
||||
|
||||
override suspend fun emit(value: ReversibleAction) {
|
||||
val handle = value.handle
|
||||
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
|
||||
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
|
||||
if (snackbarAnchor?.isShown == true) {
|
||||
snackbar.anchorView = snackbarAnchor
|
||||
when (val activity = snackbarHost.context.findActivity()) {
|
||||
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
|
||||
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
|
||||
}
|
||||
if (handle != null) {
|
||||
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.View
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.Disposable
|
||||
@@ -125,6 +126,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
private var model: ChipModel? = null
|
||||
private var imageRequest: Disposable? = null
|
||||
|
||||
private val defaultStrokeColor = chipStrokeColor
|
||||
private val defaultTextColor = textColors
|
||||
|
||||
init {
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||
setChipDrawable(drawable)
|
||||
@@ -154,6 +158,14 @@ class ChipsView @JvmOverloads constructor(
|
||||
isChecked = false
|
||||
isCheckable = false
|
||||
}
|
||||
if (model.tint == 0) {
|
||||
chipStrokeColor = defaultStrokeColor
|
||||
setTextColor(defaultTextColor)
|
||||
} else {
|
||||
val tint = ContextCompat.getColorStateList(context, model.tint)
|
||||
chipStrokeColor = tint
|
||||
setTextColor(tint)
|
||||
}
|
||||
bindIcon(model)
|
||||
isCheckedIconVisible = model.isChecked
|
||||
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
|
||||
class WindowInsetHolder @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -24,9 +26,9 @@ class WindowInsetHolder @JvmOverloads constructor(
|
||||
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
|
||||
.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val gravity = getLayoutGravity()
|
||||
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||
Gravity.LEFT -> barsInsets.left
|
||||
Gravity.RIGHT -> barsInsets.right
|
||||
val newWidth = when (gravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) {
|
||||
Gravity.START -> barsInsets.start(this)
|
||||
Gravity.END -> barsInsets.end(this)
|
||||
else -> 0
|
||||
}
|
||||
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -15,6 +15,7 @@ private const val TYPE_TEXT = "text/plain"
|
||||
private const val TYPE_IMAGE = "image/*"
|
||||
private const val TYPE_CBZ = "application/x-cbz"
|
||||
|
||||
@Deprecated("")
|
||||
class ShareHelper(private val context: Context) {
|
||||
|
||||
fun shareMangaLink(manga: Manga) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.collection.SieveCache
|
||||
|
||||
class SynchronizedSieveCache<K : Any, V : Any>(
|
||||
private val delegate: SieveCache<K, V>,
|
||||
) {
|
||||
|
||||
constructor(maxSize: Int) : this(SieveCache<K, V>(maxSize))
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
operator fun get(key: K): V? = synchronized(lock) {
|
||||
delegate[key]
|
||||
}
|
||||
|
||||
fun put(key: K, value: V): V? = synchronized(lock) {
|
||||
delegate.put(key, value)
|
||||
}
|
||||
|
||||
fun remove(key: K) = synchronized(lock) {
|
||||
delegate.remove(key)
|
||||
}
|
||||
|
||||
fun evictAll() = synchronized(lock) {
|
||||
delegate.evictAll()
|
||||
}
|
||||
|
||||
fun trimToSize(maxSize: Int) = synchronized(lock) {
|
||||
delegate.trimToSize(maxSize)
|
||||
}
|
||||
|
||||
fun removeIf(predicate: (K, V) -> Boolean) = synchronized(lock) {
|
||||
delegate.removeIf(predicate)
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
@@ -217,6 +215,7 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
|
||||
WebViewCompat.setAudioMuted(this@configureForParser, true)
|
||||
}
|
||||
databaseEnabled = true
|
||||
allowContentAccess = false
|
||||
if (userAgentOverride != null) {
|
||||
userAgentString = userAgentOverride
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ fun File.subdir(name: String) = File(this, name).also {
|
||||
if (!it.exists()) it.mkdirs()
|
||||
}
|
||||
|
||||
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
|
||||
fun File.takeIfReadable() = takeIf { it.isReadable() }
|
||||
|
||||
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
|
||||
fun File.takeIfWriteable() = takeIf { it.isWriteable() }
|
||||
|
||||
fun File.isNotEmpty() = length() != 0L
|
||||
|
||||
@@ -110,3 +110,11 @@ fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VER
|
||||
|
||||
val File.normalizedExtension: String?
|
||||
get() = MimeTypes.getNormalizedExtension(name)
|
||||
|
||||
fun File.isReadable() = runCatching {
|
||||
canRead()
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun File.isWriteable() = runCatching {
|
||||
canWrite()
|
||||
}.getOrDefault(false)
|
||||
|
||||
@@ -4,10 +4,12 @@ import android.os.SystemClock
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -58,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 ->
|
||||
@@ -142,3 +147,12 @@ suspend fun <T> SendChannel<T>.sendNotNull(item: T?) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> MutableStateFlow<List<T>>.append(item: T) {
|
||||
update { list -> list + item }
|
||||
}
|
||||
|
||||
fun <T> Flow<T>.concat(other: Flow<T>) = flow {
|
||||
emitAll(this@concat)
|
||||
emitAll(other)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ancestors
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
|
||||
@@ -29,3 +31,8 @@ tailrec fun <T> Fragment.findParentCallback(cls: Class<T>): T? {
|
||||
else -> parent.findParentCallback(cls)
|
||||
}
|
||||
}
|
||||
|
||||
val Fragment.container: FragmentContainerView?
|
||||
get() = view?.ancestors?.firstNotNullOfOrNull {
|
||||
it as? FragmentContainerView // TODO check if direct parent
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsCompat.Type.InsetsType
|
||||
|
||||
fun Insets.end(view: View): Int {
|
||||
return if (view.isRtl) left else right
|
||||
@@ -11,20 +13,69 @@ fun Insets.start(view: View): Int {
|
||||
return if (view.isRtl) right else left
|
||||
}
|
||||
|
||||
fun Insets.consume(
|
||||
@Deprecated("")
|
||||
val WindowInsetsCompat.systemBarsInsets: Insets
|
||||
get() = getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
@Deprecated("")
|
||||
fun WindowInsetsCompat.consumeSystemBarsInsets(
|
||||
left: Boolean = false,
|
||||
top: Boolean = false,
|
||||
right: Boolean = false,
|
||||
bottom: Boolean = false,
|
||||
): Insets = Insets.of(
|
||||
/* left = */ if (left) 0 else this.left,
|
||||
/* top = */ if (top) 0 else this.top,
|
||||
/* right = */ if (right) 0 else this.right,
|
||||
/* bottom = */ if (bottom) 0 else this.bottom,
|
||||
)
|
||||
): WindowInsetsCompat {
|
||||
val barsInsets = systemBarsInsets
|
||||
val insets = Insets.of(
|
||||
if (left) 0 else barsInsets.left,
|
||||
if (top) 0 else barsInsets.top,
|
||||
if (right) 0 else barsInsets.right,
|
||||
if (bottom) 0 else barsInsets.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(this)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), insets)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun WindowInsetsCompat.consume(
|
||||
v: View,
|
||||
@InsetsType typeMask: Int,
|
||||
start: Boolean = false,
|
||||
top: Boolean = false,
|
||||
end: Boolean = false,
|
||||
bottom: Boolean = false,
|
||||
): WindowInsetsCompat {
|
||||
val insets = getInsets(typeMask)
|
||||
val newInsets = Insets.of(
|
||||
/* left = */ if (if (v.isRtl) end else start) 0 else insets.left,
|
||||
/* top = */ if (top) 0 else insets.top,
|
||||
/* right = */ if (if (v.isRtl) start else end) 0 else insets.right,
|
||||
/* bottom = */ if (bottom) 0 else insets.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(this)
|
||||
.setInsets(typeMask, newInsets)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Insets.consumeRelative(
|
||||
fun WindowInsetsCompat.consumeAll(
|
||||
@InsetsType typeMask: Int,
|
||||
): WindowInsetsCompat = WindowInsetsCompat.Builder(this)
|
||||
.setInsets(typeMask, Insets.NONE)
|
||||
.build()
|
||||
|
||||
@Deprecated("")
|
||||
fun WindowInsetsCompat.consumeSystemBarsInsets(
|
||||
view: View,
|
||||
start: Boolean = false,
|
||||
top: Boolean = false,
|
||||
end: Boolean = false,
|
||||
bottom: Boolean = false,
|
||||
): WindowInsetsCompat = consume(view, WindowInsetsCompat.Type.systemBars(), start, top, end, bottom)
|
||||
|
||||
@Deprecated("")
|
||||
fun WindowInsetsCompat.consumeAllSystemBarsInsets() = consumeAll(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
@Deprecated("")
|
||||
fun Insets.consume(
|
||||
view: View,
|
||||
start: Boolean = false,
|
||||
top: Boolean = false,
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +42,7 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||
import java.io.File
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketException
|
||||
@@ -52,6 +54,8 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
||||
private const val MSG_CONNECTION_RESET = "Connection reset"
|
||||
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||
|
||||
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
@@ -86,8 +90,9 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
|
||||
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
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,
|
||||
@@ -225,3 +230,35 @@ fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
fun FileNotFoundException.getFile(): File? {
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
}
|
||||
|
||||
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,10 @@ import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Checkable
|
||||
import androidx.annotation.GravityInt
|
||||
import androidx.appcompat.widget.ActionMenuView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
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
|
||||
@@ -27,8 +23,6 @@ import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.ui.util.InsetsToMarginsListener
|
||||
import org.koitharu.kotatsu.core.ui.util.InsetsToPaddingListener
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
@@ -160,9 +154,9 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
|
||||
|
||||
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
|
||||
if (value) {
|
||||
if (!isVisible) show()
|
||||
show()
|
||||
} else {
|
||||
if (isVisible) hide()
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,19 +188,3 @@ fun Chip.setProgressIcon() {
|
||||
chipIcon = progressDrawable
|
||||
progressDrawable.start()
|
||||
}
|
||||
|
||||
private fun View.marginsSnapshot(): Insets = (layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
|
||||
Insets.of(lp.leftMargin, lp.topMargin, lp.rightMargin, lp.bottomMargin)
|
||||
} ?: Insets.NONE
|
||||
|
||||
private fun View.paddingSnapshot(): Insets = Insets.of(paddingLeft, paddingTop, paddingRight, paddingBottom)
|
||||
|
||||
fun View.consumeInsetsAsPadding(@GravityInt sides: Int) = ViewCompat.setOnApplyWindowInsetsListener(
|
||||
this,
|
||||
InsetsToPaddingListener(sides, paddingSnapshot()),
|
||||
)
|
||||
|
||||
fun View.consumeInsetsAsMargins(@GravityInt sides: Int) = ViewCompat.setOnApplyWindowInsetsListener(
|
||||
this,
|
||||
InsetsToMarginsListener(sides, marginsSnapshot()),
|
||||
)
|
||||
|
||||
@@ -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,18 +2,16 @@ package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.transition.TransitionManager
|
||||
import android.text.SpannedString
|
||||
import android.view.Gravity
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
@@ -21,6 +19,7 @@ import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.view.updatePaddingRelative
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import androidx.transition.TransitionManager
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
@@ -38,7 +37,6 @@ import coil3.transform.RoundedCornersTransformation
|
||||
import coil3.util.CoilUtils
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
@@ -48,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
|
||||
@@ -59,7 +58,6 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
|
||||
@@ -72,7 +70,7 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.LocaleUtils
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeRelative
|
||||
import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
@@ -81,6 +79,7 @@ import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
|
||||
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
@@ -99,17 +98,19 @@ import org.koitharu.kotatsu.details.ui.model.HistoryInfo
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
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.main.ui.owners.BottomSheetOwner
|
||||
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
|
||||
@@ -118,10 +119,14 @@ import com.google.android.material.R as materialR
|
||||
@AndroidEntryPoint
|
||||
class DetailsActivity :
|
||||
BaseActivity<ActivityDetailsBinding>(),
|
||||
View.OnClickListener, OnApplyWindowInsetsListener,
|
||||
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
|
||||
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
|
||||
OnContextClickListenerCompat, SwipeRefreshLayout.OnRefreshListener {
|
||||
View.OnClickListener,
|
||||
View.OnLayoutChangeListener,
|
||||
ViewTreeObserver.OnDrawListener,
|
||||
ChipsView.OnChipClickListener,
|
||||
OnListItemClickListener<Bookmark>,
|
||||
SwipeRefreshLayout.OnRefreshListener,
|
||||
AuthorSpan.OnAuthorClickListener,
|
||||
BottomSheetOwner {
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutManager: AppShortcutManager
|
||||
@@ -129,24 +134,21 @@ class DetailsActivity :
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var listMapper: MangaListMapper
|
||||
|
||||
private val viewModel: DetailsViewModel by viewModels()
|
||||
private lateinit var menuProvider: DetailsMenuProvider
|
||||
private lateinit var infoBinding: LayoutDetailsTableBinding
|
||||
|
||||
override val bottomSheet: View?
|
||||
get() = viewBinding.containerBottomSheet
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
|
||||
infoBinding = LayoutDetailsTableBinding.bind(viewBinding.root)
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
setDisplayHomeAsUp(true, false)
|
||||
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)
|
||||
@@ -156,17 +158,18 @@ 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)
|
||||
viewBinding.containerBottomSheet?.let { sheet ->
|
||||
sheet.setOnClickListener(this)
|
||||
sheet.addOnLayoutChangeListener(this)
|
||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
|
||||
BottomSheetBehavior.from(sheet).addBottomSheetCallback(
|
||||
DetailsBottomSheetCallback(viewBinding.swipeRefreshLayout, checkNotNull(viewBinding.navbarDim)),
|
||||
)
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(viewBinding.root, this)
|
||||
|
||||
val appRouter = router
|
||||
viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
@@ -177,7 +180,7 @@ class DetailsActivity :
|
||||
.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
|
||||
viewModel.onActionDone
|
||||
.filterNot { appRouter.isChapterPagesSheetShown() }
|
||||
.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
|
||||
.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView))
|
||||
combine(viewModel.historyInfo, viewModel.isLoading, ::Pair).observe(this) {
|
||||
onHistoryChanged(it.first, it.second)
|
||||
}
|
||||
@@ -189,16 +192,7 @@ class DetailsActivity :
|
||||
val menuInvalidator = MenuInvalidator(this)
|
||||
viewModel.isStatsAvailable.observe(this, menuInvalidator)
|
||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||
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.tags.observe(this, ::onTagsChanged)
|
||||
viewModel.chapters.observe(this, PrefetchObserver(this))
|
||||
viewModel.onDownloadStarted
|
||||
.filterNot { appRouter.isChapterPagesSheetShown() }
|
||||
@@ -212,43 +206,42 @@ 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,
|
||||
)
|
||||
}
|
||||
|
||||
R.id.button_description_more -> {
|
||||
val tv = viewBinding.textViewDescription
|
||||
TransitionManager.beginDelayedTransition(tv.parentView)
|
||||
if (tv.context.isAnimationsEnabled) {
|
||||
tv.parentView?.let {
|
||||
TransitionManager.beginDelayedTransition(it)
|
||||
}
|
||||
}
|
||||
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
|
||||
tv.maxLines = Integer.MAX_VALUE
|
||||
} else {
|
||||
@@ -257,17 +250,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)
|
||||
@@ -279,45 +272,15 @@ 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)
|
||||
}
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
|
||||
override fun onLongClick(v: View): Boolean = when (v.id) {
|
||||
R.id.button_read -> {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
|
||||
!isIncognitoMode && history != null
|
||||
}
|
||||
menu.setOnMenuItemClickListener(this)
|
||||
menu.setForceShowIcon(true)
|
||||
menu.show()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_incognito -> {
|
||||
openReader(isIncognitoMode = true)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_forget -> {
|
||||
viewModel.removeFromHistory()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
router.openReader(ReaderIntent.Builder(view.context).bookmark(item).incognito(true).build())
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
@@ -346,7 +309,6 @@ class DetailsActivity :
|
||||
oldBottom: Int
|
||||
) {
|
||||
with(viewBinding) {
|
||||
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
|
||||
containerBottomSheet?.let { sheet ->
|
||||
val peekHeight = BottomSheetBehavior.from(sheet).peekHeight
|
||||
if (scrollView.paddingBottom != peekHeight) {
|
||||
@@ -357,7 +319,8 @@ class DetailsActivity :
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
val barsInsets = insets.getInsets(typeMask)
|
||||
if (viewBinding.cardChapters != null) {
|
||||
// landscape
|
||||
viewBinding.cardChapters?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
@@ -372,12 +335,11 @@ class DetailsActivity :
|
||||
viewBinding.appbar.updatePaddingRelative(
|
||||
start = barsInsets.start(v),
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(
|
||||
WindowInsetsCompat.Type.systemBars(),
|
||||
barsInsets.consumeRelative(v, end = true, bottom = true),
|
||||
).build()
|
||||
return insets.consume(v, typeMask, bottom = true, end = true)
|
||||
} else {
|
||||
viewBinding.navbarDim?.updateLayoutParams {
|
||||
height = barsInsets.bottom
|
||||
}
|
||||
return insets
|
||||
}
|
||||
}
|
||||
@@ -447,11 +409,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
|
||||
@@ -492,7 +464,6 @@ class DetailsActivity :
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
bindTags(manga)
|
||||
title = manga.title
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
@@ -536,28 +507,9 @@ class DetailsActivity :
|
||||
progress.isVisible = info.history != null
|
||||
}
|
||||
|
||||
private fun openReader(isIncognitoMode: Boolean) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
if (viewModel.historyInfo.value.isChapterMissing) {
|
||||
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
router.openReader(
|
||||
ReaderIntent.Builder(this)
|
||||
.manga(manga)
|
||||
.branch(viewModel.selectedBranchValue)
|
||||
.incognito(isIncognitoMode)
|
||||
.build(),
|
||||
)
|
||||
if (isIncognitoMode) {
|
||||
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindTags(manga: Manga) {
|
||||
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
|
||||
viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags))
|
||||
private fun onTagsChanged(tags: Collection<ChipsView.ChipModel>) {
|
||||
viewBinding.chipsTags.isVisible = tags.isNotEmpty()
|
||||
viewBinding.chipsTags.setChips(tags)
|
||||
}
|
||||
|
||||
private fun loadCover(imageUrl: String?) {
|
||||
@@ -593,6 +545,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>?> {
|
||||
|
||||
@@ -3,11 +3,10 @@ package org.koitharu.kotatsu.details.ui
|
||||
import android.view.View
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.koitharu.kotatsu.core.ui.widgets.WindowInsetHolder
|
||||
|
||||
class DetailsBottomSheetCallback(
|
||||
private val swipeRefreshLayout: SwipeRefreshLayout,
|
||||
private val navbarDimView: WindowInsetHolder,
|
||||
private val navbarDimView: View,
|
||||
) : BottomSheetBehavior.BottomSheetCallback() {
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,20 +5,18 @@ import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
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.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
|
||||
class DetailsMenuProvider(
|
||||
private val activity: FragmentActivity,
|
||||
@@ -27,15 +25,19 @@ class DetailsMenuProvider(
|
||||
private val appShortcutManager: AppShortcutManager,
|
||||
) : MenuProvider {
|
||||
|
||||
private val router: AppRouter
|
||||
get() = activity.router
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_details, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
val manga = viewModel.manga.value
|
||||
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
|
||||
@@ -47,51 +49,44 @@ class DetailsMenuProvider(
|
||||
val manga = viewModel.getMangaOrNull() ?: return false
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_share -> {
|
||||
val shareHelper = ShareHelper(activity)
|
||||
if (manga.isLocal) {
|
||||
shareHelper.shareCbz(listOf(manga.url.toUri().toFile()))
|
||||
} else {
|
||||
shareHelper.shareMangaLink(manga)
|
||||
}
|
||||
router.showShareDialog(manga)
|
||||
}
|
||||
|
||||
R.id.action_delete -> {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(activity.getString(R.string.text_delete_local_manga, manga.title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
buildAlertDialog(activity) {
|
||||
setTitle(R.string.delete_manga)
|
||||
setMessage(activity.getString(R.string.text_delete_local_manga, manga.title))
|
||||
setPositiveButton(R.string.delete) { _, _ -> viewModel.deleteLocal() }
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
R.id.action_save -> {
|
||||
activity.router.showDownloadDialog(manga, snackbarHost)
|
||||
router.showDownloadDialog(manga, snackbarHost)
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
activity.router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title)
|
||||
router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title)
|
||||
}
|
||||
|
||||
R.id.action_online -> {
|
||||
activity.router.openDetails(manga)
|
||||
router.openDetails(viewModel.remoteManga.value ?: return false)
|
||||
}
|
||||
|
||||
R.id.action_related -> {
|
||||
activity.router.openSearch(manga.title)
|
||||
router.openSearch(manga.title)
|
||||
}
|
||||
|
||||
R.id.action_alternatives -> {
|
||||
activity.router.openAlternatives(manga)
|
||||
router.openAlternatives(manga)
|
||||
}
|
||||
|
||||
R.id.action_stats -> {
|
||||
activity.router.showStatisticSheet(manga)
|
||||
router.showStatisticSheet(manga)
|
||||
}
|
||||
|
||||
R.id.action_scrobbling -> {
|
||||
activity.router.showScrobblingSelectorSheet(manga, null)
|
||||
router.showScrobblingSelectorSheet(manga, null)
|
||||
}
|
||||
|
||||
R.id.action_shortcut -> {
|
||||
|
||||
@@ -152,6 +152,10 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val tags = manga.mapLatest {
|
||||
mangaListMapper.mapTags(it?.tags.orEmpty())
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val branches: StateFlow<List<MangaBranch>> = combine(
|
||||
mangaDetails,
|
||||
selectedBranch,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
@@ -88,13 +89,15 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
viewModel.newChaptersCount.observe(viewLifecycleOwner, ::onNewChaptersChanged)
|
||||
if (dialog != null) {
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this))
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager, null))
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager))
|
||||
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager))
|
||||
} else {
|
||||
PeekHeightController(arrayOf(binding.headerBar, binding.toolbar)).attach()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||
return
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.bookmarks
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
@@ -9,6 +8,7 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -29,11 +29,12 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
@@ -94,7 +95,6 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
headerClickListener = null,
|
||||
)
|
||||
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
|
||||
binding.recyclerView.consumeInsetsAsPadding(Gravity.START or Gravity.BOTTOM or Gravity.END)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
setHasFixedSize(true)
|
||||
@@ -116,6 +116,17 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.systemBarsInsets
|
||||
viewBinding?.recyclerView?.setPadding(
|
||||
barsInsets.left,
|
||||
barsInsets.top,
|
||||
barsInsets.right,
|
||||
barsInsets.bottom,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
spanResolver = null
|
||||
bookmarksAdapter = null
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -4,8 +4,6 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
@@ -49,7 +47,6 @@ import kotlin.math.roundToInt
|
||||
class ChaptersFragment :
|
||||
BaseFragment<FragmentChaptersBinding>(),
|
||||
OnListItemClickListener<ChapterListItem>,
|
||||
OnApplyWindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ChipsView.OnChipClickListener {
|
||||
|
||||
@@ -84,7 +81,6 @@ class ChaptersFragment :
|
||||
LinearLayoutManager(context)
|
||||
}
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
|
||||
with(binding.recyclerViewChapters) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, true))
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
@@ -10,6 +9,7 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -33,7 +33,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -118,7 +118,6 @@ class PagesFragment :
|
||||
clickListener = this@PagesFragment,
|
||||
)
|
||||
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
|
||||
binding.recyclerView.consumeInsetsAsPadding(Gravity.START or Gravity.BOTTOM or Gravity.END)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
@@ -150,6 +149,18 @@ class PagesFragment :
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeBask = WindowInsetsCompat.Type.systemBars()
|
||||
val barsInsets = insets.getInsets(typeBask)
|
||||
viewBinding?.recyclerView?.setPadding(
|
||||
barsInsets.left,
|
||||
barsInsets.top,
|
||||
barsInsets.right,
|
||||
barsInsets.bottom,
|
||||
)
|
||||
return insets.consumeAll(typeBask)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: PageThumbnail, view: View) {
|
||||
if (selectionController?.onItemClick(item.page.id) == true) {
|
||||
return
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.widget.RatingBar
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import coil3.ImageLoader
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -18,6 +20,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
@@ -81,6 +84,15 @@ class ScrobblingInfoSheet :
|
||||
menu = null
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.root?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
viewModel.updateScrobbling(
|
||||
index = scrobblerIndex,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package org.koitharu.kotatsu.download.ui.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -17,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
||||
@@ -42,7 +43,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayHomeAsUp(true, false)
|
||||
val downloadsAdapter = DownloadsAdapter(this, coil, this)
|
||||
val decoration = TypedListSpacingDecoration(this, false)
|
||||
selectionController = ListSelectionController(
|
||||
@@ -52,7 +53,6 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
callback = this,
|
||||
)
|
||||
with(viewBinding.recyclerView) {
|
||||
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(decoration)
|
||||
adapter = downloadsAdapter
|
||||
@@ -68,6 +68,23 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
||||
viewModel.hasCancellableWorks.observe(this, menuInvalidator)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
left = bars.left,
|
||||
right = bars.right,
|
||||
bottom = bars.bottom,
|
||||
)
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = bars.left,
|
||||
right = bars.right,
|
||||
top = bars.top,
|
||||
)
|
||||
return return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onItemClick(item: DownloadItemModel, view: View) {
|
||||
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
|
||||
return
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user