Merge branch 'devel' into Instant_history

pull/332/head
Isira Seneviratne 3 years ago
commit 667c9ccfda

@ -0,0 +1,3 @@
[weblate]
url = https://hosted.weblate.org/api/
translation = kotatsu/strings

@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 545 versionCode 552
versionName '5.1.1' versionName '5.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -82,17 +82,17 @@ dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:cae7073f87') { implementation('com.github.KotatsuApp:kotatsu-parsers:f732582d55') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.1' implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.5.7' implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
@ -100,7 +100,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0' implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0' implementation 'com.google.android.material:material:1.9.0'
@ -109,7 +109,7 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.google.guava:guava:31.1-android') { implementation('com.google.guava:guava:32.0.0-android') {
exclude group: 'com.google.guava', module: 'failureaccess' exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual' exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
@ -119,8 +119,8 @@ dependencies {
implementation 'androidx.room:room-ktx:2.5.1' implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1' kapt 'androidx.room:room-compiler:2.5.1'
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.3.0' implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
@ -131,8 +131,8 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.3.0' implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.3.0' implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
@ -140,7 +140,7 @@ dependencies {
implementation 'ch.acra:acra-http:5.9.7' implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7' implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227' testImplementation 'org.json:json:20230227'

@ -8,7 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -19,11 +18,12 @@ import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.awaitForIdle import org.koitharu.kotatsu.awaitForIdle
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ShortcutsUpdaterTest { class AppShortcutManagerTest {
@get:Rule @get:Rule
var hiltRule = HiltAndroidRule(this) var hiltRule = HiltAndroidRule(this)
@ -32,7 +32,7 @@ class ShortcutsUpdaterTest {
lateinit var historyRepository: HistoryRepository lateinit var historyRepository: HistoryRepository
@Inject @Inject
lateinit var shortcutsUpdater: ShortcutsUpdater lateinit var appShortcutManager: AppShortcutManager
@Inject @Inject
lateinit var database: MangaDatabase lateinit var database: MangaDatabase
@ -72,6 +72,6 @@ class ShortcutsUpdaterTest {
private suspend fun awaitUpdate() { private suspend fun awaitUpdate() {
val instrumentation = InstrumentationRegistry.getInstrumentation() val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.awaitForIdle() instrumentation.awaitForIdle()
shortcutsUpdater.await() appShortcutManager.await()
} }
} }

@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject

@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null) get() = ConfigKey.Domain()
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java) get() = EnumSet.allOf(SortOrder::class.java)

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.util package org.koitharu.kotatsu.core.util
import android.util.Log import android.util.Log
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver

@ -1,3 +1,3 @@
package org.koitharu.kotatsu.util.ext package org.koitharu.kotatsu.core.util.ext
fun Throwable.printStackTraceDebug() = printStackTrace() fun Throwable.printStackTraceDebug() = printStackTrace()

@ -102,6 +102,10 @@
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"

@ -22,8 +22,8 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import javax.inject.Inject import javax.inject.Inject
@ -67,6 +67,10 @@ class KotatsuApp : Application(), Configuration.Provider {
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf( excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+", "sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
) )
httpSender { httpSender {
uri = getString(R.string.url_error_report) uri = getString(R.string.url_error_report)
@ -83,6 +87,7 @@ class KotatsuApp : Application(), Configuration.Provider {
ReportField.PHONE_MODEL, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES, ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {

@ -1,6 +1,10 @@
package org.koitharu.kotatsu.bookmarks.data package org.koitharu.kotatsu.bookmarks.data
import androidx.room.* import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@ -18,7 +22,7 @@ abstract class BookmarksDao {
@Transaction @Transaction
@Query( @Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at" "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at",
) )
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>> abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
@ -29,5 +33,8 @@ abstract class BookmarksDao {
abstract suspend fun delete(entity: BookmarkEntity) abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long) abstract suspend fun delete(mangaId: Long, pageId: Long): Int
}
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
}

@ -1,7 +1,8 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.* import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date
class Bookmark( class Bookmark(
val manga: Manga, val manga: Manga,
@ -14,6 +15,20 @@ class Bookmark(
val percent: Float, val percent: Float,
) { ) {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
preview = null,
source = manga.source,
)
private fun isImageUrlDirect(): Boolean {
return imageUrl.substringAfterLast('.').length in 2..4
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@ -27,9 +42,7 @@ class Bookmark(
if (scroll != other.scroll) return false if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false if (createdAt != other.createdAt) return false
if (percent != other.percent) return false return percent == other.percent
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@ -43,4 +56,4 @@ class Bookmark(
result = 31 * result + percent.hashCode() result = 31 * result + percent.hashCode()
return result return result
} }
} }

@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
@ -52,8 +52,14 @@ class BookmarksRepository @Inject constructor(
} }
} }
suspend fun removeBookmark(mangaId: Long, pageId: Long) { suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
db.bookmarksDao.delete(mangaId, pageId) check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
"Bookmark not found"
}
}
suspend fun removeBookmark(bookmark: Bookmark) {
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
} }
suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle { suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle {

@ -30,6 +30,8 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@ -81,8 +83,8 @@ class BookmarksFragment :
binding.recyclerView.addItemDecoration(spacingDecoration) binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
} }
override fun onDestroyView() { override fun onDestroyView() {
@ -93,8 +95,11 @@ class BookmarksFragment :
override fun onItemClick(item: Bookmark, view: View) { override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.manga, item.pageId) != true) { if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.newIntent(view.context, item) val intent = ReaderActivity.IntentBuilder(view.context)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle()) .bookmark(item)
.incognito(true)
.build()
startActivity(intent, scaleUpActivityOptionsOf(view))
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
} }
} }

@ -1,18 +1,21 @@
package org.koitharu.kotatsu.bookmarks.ui package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.asFlowLiveData import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
@ -25,9 +28,9 @@ class BookmarksViewModel @Inject constructor(
private val repository: BookmarksRepository, private val repository: BookmarksRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val content: LiveData<List<ListModel>> = repository.observeBookmarks() val content: StateFlow<List<ListModel>> = repository.observeBookmarks()
.map { list -> .map { list ->
if (list.isEmpty()) { if (list.isEmpty()) {
listOf( listOf(
@ -43,12 +46,12 @@ class BookmarksViewModel @Inject constructor(
} }
} }
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids) val handle = repository.removeBookmarks(ids)
onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle)) onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
} }
} }
} }

@ -28,7 +28,8 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener) binding.root.setOnLongClickListener(listener)
bind { bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run { val data: Any = item.directImageUrl ?: item.toMangaPage()
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
size(CoverSizeResolver(binding.imageViewThumb)) size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

@ -19,7 +19,9 @@ class BookmarksAdapter(
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() { private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.manga.id == newItem.manga.id && oldItem.pageId == newItem.pageId return oldItem.manga.id == newItem.manga.id &&
oldItem.chapterId == newItem.chapterId &&
oldItem.page == newItem.page
} }
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {

@ -103,6 +103,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
viewBinding.webView.stopLoading()
viewBinding.webView.destroy() viewBinding.webView.destroy()
} }

@ -0,0 +1,175 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
with(viewBinding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: CommonHeadersInterceptor.userAgentFallback
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onDestroy() {
viewBinding.webView.run {
stopLoading()
destroy()
}
super.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
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 onPageLoaded() {
viewBinding.progressBar.isInvisible = true
}
override fun onCheckPassed() {
pendingResult = RESULT_OK
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
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
return newIntent(context, input.first, input.second)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
}
}
companion object {
const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
fun newIntent(
context: Context,
url: String,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}
}
}
}

@ -1,123 +0,0 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
private lateinit var url: String
private val pendingResult = Bundle(1)
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
if (url.isEmpty()) {
dismissAllowingStateLoss()
} else {
binding.webView.loadUrl(url)
}
}
override fun onDestroyView() {
requireViewBinding().webView.stopLoading()
requireViewBinding().webView.destroy()
onBackPressedCallback = null
super.onDestroyView()
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
}
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() {
super.onResume()
requireViewBinding().webView.onResume()
}
override fun onPause() {
requireViewBinding().webView.onPause()
super.onPause()
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(TAG, pendingResult)
super.onDismiss(dialog)
}
override fun onPageLoaded() {
viewBinding?.progressBar?.isInvisible = true
}
override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true)
dismissAllowingStateLoss()
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
companion object {
const val TAG = "CloudFlareDialog"
const val EXTRA_RESULT = "result"
private const val ARG_URL = "url"
private const val ARG_UA = "ua"
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
putString(ARG_URL, url)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putString(ARG_UA, it)
}
}
}
}

@ -29,22 +29,22 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.* import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
@ -86,7 +86,8 @@ interface AppModule {
@ApplicationContext context: Context, @ApplicationContext context: Context,
@MangaHttpClient okHttpClient: OkHttpClient, @MangaHttpClient okHttpClient: OkHttpClient,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
pagesCache: PagesCache, imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
): ImageLoader { ): ImageLoader {
val diskCacheFactory = { val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir val rootDir = context.externalCacheDir ?: context.cacheDir
@ -108,7 +109,8 @@ interface AppModule {
.add(SvgDecoder.Factory()) .add(SvgDecoder.Factory())
.add(CbzFetcher.Factory()) .add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory)) .add(pageFetcherFactory)
.add(imageProxyInterceptor)
.build(), .build(),
).build() ).build()
} }
@ -124,12 +126,12 @@ interface AppModule {
@ElementsIntoSet @ElementsIntoSet
fun provideDatabaseObservers( fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater, widgetUpdater: WidgetUpdater,
shortcutsUpdater: ShortcutsUpdater, appShortcutManager: AppShortcutManager,
backupObserver: BackupObserver, backupObserver: BackupObserver,
syncController: SyncController, syncController: SyncController,
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf( ): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
widgetUpdater, widgetUpdater,
shortcutsUpdater, appShortcutManager,
backupObserver, backupObserver,
syncController, syncController,
) )
@ -140,10 +142,12 @@ interface AppModule {
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle, activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator, incognitoModeIndicator: IncognitoModeIndicator,
acraScreenLogger: AcraScreenLogger,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper, appProtectHelper,
activityRecreationHandle, activityRecreationHandle,
incognitoModeIndicator, incognitoModeIndicator,
acraScreenLogger,
) )
@Provides @Provides

@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration11To12
import org.koitharu.kotatsu.core.db.migrations.Migration12To13 import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14 import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@ -48,7 +49,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 15 const val DATABASE_VERSION = 16
@Database( @Database(
entities = [ entities = [
@ -100,6 +101,7 @@ val databaseMigrations: Array<Migration>
Migration12To13(), Migration12To13(),
Migration13To14(), Migration13To14(),
Migration14To15(), Migration14To15(),
Migration15To16(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

@ -23,4 +23,5 @@ data class MangaPrefsEntity(
@ColumnInfo(name = "mode") val mode: Int, @ColumnInfo(name = "mode") val mode: Int,
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
@ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
) )

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration15To16 : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
}
}

@ -22,10 +22,7 @@ class DialogErrorObserver(
fragment: Fragment?, fragment: Fragment?,
) : this(host, fragment, null, null) ) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) { override suspend fun emit(value: Throwable) {
if (value == null) {
return
}
val listener = DialogListener(value) val listener = DialogListener(value)
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context) val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
.setMessage(value.getDisplayMessage(host.context.resources)) .setMessage(value.getDisplayMessage(host.context.resources))

@ -7,8 +7,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.findActivity
@ -19,7 +19,7 @@ abstract class ErrorObserver(
protected val fragment: Fragment?, protected val fragment: Fragment?,
private val resolver: ExceptionResolver?, private val resolver: ExceptionResolver?,
private val onResolved: Consumer<Boolean>?, private val onResolved: Consumer<Boolean>?,
) : Observer<Throwable?> { ) : FlowCollector<Throwable> {
protected val activity = host.context.findActivity() protected val activity = host.context.findActivity()

@ -6,15 +6,13 @@ import androidx.annotation.StringRes
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.isSuccess
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -23,20 +21,26 @@ import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor( class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private val activity: FragmentActivity?,
private val fragment: Fragment?,
) : ActivityResultCallback<TaggedActivityResult> {
private val continuations = ArrayMap<String, Continuation<Boolean>>(1) private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
private lateinit var sourceAuthContract: ActivityResultLauncher<MangaSource> private val activity: FragmentActivity?
private val fragment: Fragment?
constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) { private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
constructor(activity: FragmentActivity) {
this.activity = activity
fragment = null
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this) sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
} }
constructor(fragment: Fragment) : this(activity = null, fragment = fragment) { constructor(fragment: Fragment) {
this.fragment = fragment
activity = null
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this) sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
} }
override fun onActivityResult(result: TaggedActivityResult) { override fun onActivityResult(result: TaggedActivityResult) {
@ -58,22 +62,9 @@ class ExceptionResolver private constructor(
else -> false else -> false
} }
private suspend fun resolveCF(url: String, headers: Headers): Boolean { private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
val dialog = CloudFlareDialog.newInstance(url, headers) continuations[CloudFlareActivity.TAG] = cont
val fm = getFragmentManager() cloudflareContract.launch(url to headers)
return suspendCancellableCoroutine { cont ->
fm.clearFragmentResult(CloudFlareDialog.TAG)
continuations[CloudFlareDialog.TAG] = cont
fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result ->
continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT))
}
dialog.show(fm, CloudFlareDialog.TAG)
cont.invokeOnCancellation {
continuations.remove(CloudFlareDialog.TAG, cont)
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
dialog.dismissAllowingStateLoss()
}
}
} }
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->

@ -22,10 +22,7 @@ class SnackbarErrorObserver(
fragment: Fragment?, fragment: Fragment?,
) : this(host, fragment, null, null) ) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) { override suspend fun emit(value: Throwable) {
if (value == null) {
return
}
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
if (activity is BottomNavOwner) { if (activity is BottomNavOwner) {
snackbar.anchorView = activity.bottomNav snackbar.anchorView = activity.bottomNav

@ -16,12 +16,12 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest

@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.subdir import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat

@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
@ -23,6 +24,10 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
return acc.values.max() return acc.values.max()
} }
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.find { it.id == id }
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? { fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters val ch = chapters
if (ch.isNullOrEmpty()) { if (ch.isNullOrEmpty()) {
@ -54,3 +59,6 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key
} }
val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL

@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.IOException import java.io.IOException
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
@ -13,6 +13,10 @@ class AppProxySelector(
private val settings: AppSettings, private val settings: AppSettings,
) : ProxySelector() { ) : ProxySelector() {
init {
setDefault(this)
}
private var cachedProxy: Proxy? = null private var cachedProxy: Proxy? = null
override fun select(uri: URI?): List<Proxy> { override fun select(uri: URI?): List<Proxy> {

@ -14,6 +14,7 @@ object CommonHeaders {
const val ACCEPT_ENCODING = "Accept-Encoding" const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control" const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
val CACHE_CONTROL_NO_STORE: CacheControl val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build() get() = CacheControl.Builder().noStore().build()

@ -12,7 +12,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.IDN import java.net.IDN
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject

@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetAddress import java.net.InetAddress
import java.net.UnknownHostException import java.net.UnknownHostException

@ -0,0 +1,103 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.request.ErrorResult
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.size.Dimension
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageProxyInterceptor @Inject constructor(
private val settings: AppSettings,
) : Interceptor {
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
if (!settings.isImagesProxyEnabled) {
return chain.proceed(request)
}
val url: HttpUrl? = when (val data = request.data) {
is HttpUrl -> data
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
}
val newUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", url.toString())
.addQueryParameter("fit", "outside")
.addQueryParameter("we", null)
val size = request.sizeResolver.size()
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
val newRequest = request.newBuilder()
.data(newUrl.build())
.build()
val result = chain.proceed(newRequest)
return if (result is SuccessResult) {
result
} else {
logDebug((result as? ErrorResult)?.throwable)
chain.proceed(request).also {
if (it is SuccessResult) {
blacklist.add(url.host)
}
}
}
}
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
if (!settings.isImagesProxyEnabled) {
return okHttp.newCall(request).await()
}
val sourceUrl = request.url
val targetUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", sourceUrl.toString())
.addQueryParameter("we", null)
val newRequest = request.newBuilder()
.url(targetUrl.build())
.build()
return runCatchingCancellable {
okHttp.doCall(newRequest)
}.recover {
logDebug(it)
okHttp.doCall(request).also {
blacklist.add(sourceUrl.host)
}
}.getOrThrow()
}
private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
private fun logDebug(e: Throwable?) {
if (BuildConfig.DEBUG) {
Log.w("ImageProxy", e.toString())
}
}
}

@ -59,6 +59,7 @@ interface NetworkModule {
writeTimeout(20, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar) cookieJar(cookieJar)
proxySelector(AppProxySelector(settings)) proxySelector(AppProxySelector(settings))
proxyAuthenticator(ProxyAuthenticator(settings))
dns(DoHManager(cache, settings)) dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) { if (settings.isSSLBypassEnabled) {
bypassSSLErrors() bypassSSLErrors()

@ -0,0 +1,45 @@
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
}

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint import android.annotation.SuppressLint
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext

@ -8,7 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
private const val PREFS_NAME = "cookies" private const val PREFS_NAME = "cookies"

@ -15,7 +15,6 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -25,18 +24,19 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ShortcutsUpdater @Inject constructor( class AppShortcutManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val coil: ImageLoader, private val coil: ImageLoader,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
@ -128,8 +128,8 @@ class ShortcutsUpdater @Inject constructor(
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize.width, iconSize.height) .size(iconSize.width, iconSize.height)
.tag(manga.source) .tag(manga.source)
.precision(Precision.EXACT)
.scale(Scale.FILL) .scale(Scale.FILL)
.transformations(ThumbnailTransformation())
.build(), .build(),
).getDrawableOrThrow().toBitmap() ).getDrawableOrThrow().toBitmap()
}.fold( }.fold(
@ -142,8 +142,9 @@ class ShortcutsUpdater @Inject constructor(
.setLongLabel(manga.title) .setLongLabel(manga.title)
.setIcon(icon) .setIcon(icon)
.setIntent( .setIntent(
ReaderActivity.newIntent(context, manga.id) ReaderActivity.IntentBuilder(context)
.setAction(ReaderActivity.ACTION_MANGA_READ), .mangaId(manga.id)
.build(),
) )
} }

@ -1,41 +1,25 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt
@Reusable @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
@MangaHttpClient private val okHttpClient: OkHttpClient,
private val db: MangaDatabase, private val db: MangaDatabase,
) { ) {
@ -55,6 +39,7 @@ class MangaDataRepository @Inject constructor(
entity.copy( entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f, cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f, cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted ?: false,
), ),
) )
} }
@ -97,74 +82,18 @@ class MangaDataRepository @Inject constructor(
} }
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) {
ReaderColorFilter(cfBrightness, cfContrast) ReaderColorFilter(cfBrightness, cfContrast, cfInvert)
} else { } else {
null null
} }
} }
/**
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
suspend fun determineMangaIsWebtoon(repository: MangaRepository, pages: List<MangaPage>): Boolean {
val pageIndex = (pages.size * 0.3).roundToInt()
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = repository.getPageUrl(page)
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
}
} else {
val request = Request.Builder()
.url(url)
.get()
.tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.build()
okHttpClient.newCall(request).await().use {
runInterruptible(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
}
}
return size.width * MIN_WEBTOON_RATIO < size.height
}
private fun newEntity(mangaId: Long) = MangaPrefsEntity( private fun newEntity(mangaId: Long) = MangaPrefsEntity(
mangaId = mangaId, mangaId = mangaId,
mode = -1, mode = -1,
cfBrightness = 0f, cfBrightness = 0f,
cfContrast = 0f, cfContrast = 0f,
cfInvert = false,
) )
companion object {
private const val MIN_WEBTOON_RATIO = 2
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
options.outMimeType
}
private fun getBitmapSize(input: InputStream?): Size {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)?.recycle()
val imageHeight: Int = options.outHeight
val imageWidth: Int = options.outWidth
check(imageHeight > 0 && imageWidth > 0)
return Size(imageWidth, imageHeight)
}
}
} }

@ -12,28 +12,31 @@ import org.koitharu.kotatsu.parsers.model.Manga
class MangaIntent private constructor( class MangaIntent private constructor(
@JvmField val manga: Manga?, @JvmField val manga: Manga?,
@JvmField val mangaId: Long, @JvmField val id: Long,
@JvmField val uri: Uri?, @JvmField val uri: Uri?,
) { ) {
constructor(intent: Intent?) : this( constructor(intent: Intent?) : this(
manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga, manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
uri = intent?.data, uri = intent?.data,
) )
constructor(savedStateHandle: SavedStateHandle) : this( constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga, manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = savedStateHandle[KEY_ID] ?: ID_NONE, id = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[BaseActivity.EXTRA_DATA], uri = savedStateHandle[BaseActivity.EXTRA_DATA],
) )
constructor(args: Bundle?) : this( constructor(args: Bundle?) : this(
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga, manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
uri = null, uri = null,
) )
val mangaId: Long
get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE
companion object { companion object {
const val ID_NONE = 0L const val ID_NONE = 0L

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -22,6 +22,8 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
var defaultSortOrder: SortOrder
suspend fun getList(offset: Int, query: String): List<Manga> suspend fun getList(offset: Int, query: String): List<Manga>
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga>

@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@Reusable
class MangaTagHighlighter @Inject constructor(
@ApplicationContext context: Context,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
@ColorRes
fun getTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

@ -39,8 +39,8 @@ class RemoteMangaRepository(
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = parser.sortOrders get() = parser.sortOrders
var defaultSortOrder: SortOrder? override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull() get() = getConfig().defaultSortOrder ?: sortOrders.first()
set(value) { set(value) {
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
@ -101,7 +101,7 @@ class RemoteMangaRepository(
} }
fun getAvailableMirrors(): List<String> { fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues?.toList().orEmpty() return parser.configKeyDomain.presetValues.toList()
} }
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings

@ -86,7 +86,7 @@ class FaviconFetcher(
if (!options.diskCachePolicy.readEnabled) { if (!options.diskCachePolicy.readEnabled) {
return null return null
} }
val snapshot = diskCache.value?.get(diskCacheKey) ?: return null val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null
return SourceResult( return SourceResult(
source = snapshot.toImageSource(), source = snapshot.toImageSource(),
mimeType = null, mimeType = null,
@ -98,12 +98,12 @@ class FaviconFetcher(
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null return null
} }
val editor = diskCache.value?.edit(diskCacheKey) ?: return null val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try { try {
fileSystem.write(editor.data) { fileSystem.write(editor.data) {
body.source().readAll(this) body.source().readAll(this)
} }
return editor.commitAndGet() return editor.commitAndOpenSnapshot()
} catch (e: Throwable) { } catch (e: Throwable) {
try { try {
editor.abort() editor.abort()

@ -2,7 +2,9 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
@ -23,7 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File import java.io.File
import java.net.Proxy import java.net.Proxy
import java.util.Collections import java.util.Collections
@ -179,10 +181,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isUnstableUpdatesAllowed: Boolean val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
fun isContentPrefetchEnabled(): Boolean { val isContentPrefetchEnabled: Boolean
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) get() {
return policy.isNetworkAllowed(connectivityManager) if (isBackgroundNetworkRestricted()) {
} return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
}
var sourcesOrder: List<String> var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null) get() = prefs.getString(KEY_SOURCES_ORDER, null)
@ -271,6 +277,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderSliderEnabled: Boolean val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true) get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
@ -289,6 +298,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val proxyPort: Int val proxyPort: Int
get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0 get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
val proxyLogin: String?
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() }
val proxyPassword: String?
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() }
var localListOrder: SortOrder var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
@ -301,10 +316,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) } set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
fun isPagesPreloadEnabled(): Boolean { val isPagesPreloadEnabled: Boolean
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED) get() {
return policy.isNetworkAllowed(connectivityManager) if (isBackgroundNetworkRestricted()) {
} return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
return policy.isNetworkAllowed(connectivityManager)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> { fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList() val list = remoteSources.toMutableList()
@ -342,6 +361,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun observe() = prefs.observe() fun observe() = prefs.observe()
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
} else {
false
}
}
companion object { companion object {
const val PAGE_SWITCH_TAPS = "taps" const val PAGE_SWITCH_TAPS = "taps"
@ -430,6 +457,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TYPE = "proxy_type" const val KEY_PROXY_TYPE = "proxy_type"
const val KEY_PROXY_ADDRESS = "proxy_address" const val KEY_PROXY_ADDRESS = "proxy_address"
const val KEY_PROXY_PORT = "proxy_port" const val KEY_PROXY_PORT = "proxy_port"
const val KEY_PROXY_AUTH = "proxy_auth"
const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -1,13 +1,11 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlin.coroutines.CoroutineContext
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer() var lastValue: T = valueProducer()
@ -23,25 +21,9 @@ fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() ->
} }
} }
fun <T> AppSettings.observeAsLiveData(
context: CoroutineContext,
key: String,
valueProducer: AppSettings.() -> T,
) = liveData(context) {
emit(valueProducer())
observe().collect {
if (it == key) {
val value = valueProducer()
if (value != latestValue) {
emit(value)
}
}
}
}
fun <T> AppSettings.observeAsStateFlow( fun <T> AppSettings.observeAsStateFlow(
key: String,
scope: CoroutineScope, scope: CoroutineScope,
key: String,
valueProducer: AppSettings.() -> T, valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform { ): StateFlow<T> = observe().transform {
if (it == key) { if (it == key) {

@ -27,6 +27,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
.setView(binding.root) .setView(binding.root)
.run(::onBuildDialog) .run(::onBuildDialog)
.create() .create()
.also(::onDialogCreated)
} }
final override fun onCreateView( final override fun onCreateView(

@ -14,7 +14,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
@ -45,7 +44,7 @@ abstract class BaseActivity<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate()
@JvmField @JvmField
val actionModeDelegate = ActionModeDelegate() val actionModeDelegate = ActionModeDelegate()
@ -62,6 +61,7 @@ abstract class BaseActivity<B : ViewBinding> :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
putDataToExtras(intent) putDataToExtras(intent)
} }
@ -103,7 +103,8 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
ActivityCompat.recreate(this) // ActivityCompat.recreate(this)
error("Test")
return true return true
} }
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)

@ -18,6 +18,10 @@ import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplaySize import org.koitharu.kotatsu.core.util.ext.getDisplaySize
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@Deprecated(
"Use BaseAdaptiveSheet",
replaceWith = ReplaceWith("BaseAdaptiveSheet<B>", "org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet"),
)
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
var viewBinding: B? = null var viewBinding: B? = null

@ -26,7 +26,7 @@ abstract class BaseFragment<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this) protected val exceptionResolver = ExceptionResolver(this)
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate()
protected val actionModeDelegate: ActionModeDelegate protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
@ -44,11 +44,13 @@ abstract class BaseFragment<B : ViewBinding> :
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
insetsDelegate.onViewCreated(view) insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
onViewBindingCreated(requireViewBinding(), savedInstanceState) onViewBindingCreated(requireViewBinding(), savedInstanceState)
} }
override fun onDestroyView() { override fun onDestroyView() {
viewBinding = null viewBinding = null
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView() insetsDelegate.onDestroyView()
super.onDestroyView() super.onDestroyView()
} }

@ -3,57 +3,42 @@ package org.koitharu.kotatsu.core.ui
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.R
@Suppress("DEPRECATION")
private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
@Suppress("DEPRECATION")
private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
abstract class BaseFullscreenActivity<B : ViewBinding> : abstract class BaseFullscreenActivity<B : ViewBinding> :
BaseActivity<B>(), BaseActivity<B>() {
View.OnSystemUiVisibilityChangeListener {
private lateinit var insetsControllerCompat: WindowInsetsControllerCompat
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
with(window) { with(window) {
insetsControllerCompat = WindowInsetsControllerCompat(this, decorView)
statusBarColor = Color.TRANSPARENT statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
} else {
Color.TRANSPARENT
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attributes.layoutInDisplayCutoutMode = attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} }
decorView.setOnSystemUiVisibilityChangeListener(this@BaseFullscreenActivity)
} }
insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
showSystemUI() showSystemUI()
} }
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
final override fun onSystemUiVisibilityChange(visibility: Int) {
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
}
// TODO WindowInsetsControllerCompat works incorrect
@Suppress("DEPRECATION")
protected fun hideSystemUI() { protected fun hideSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
} }
@Suppress("DEPRECATION")
protected fun showSystemUI() { protected fun showSystemUI() {
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars())
} }
protected open fun onSystemUiVisibilityChanged(isVisible: Boolean) = Unit
} }

@ -12,10 +12,10 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.settings.SettingsHeadersFragment import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject import javax.inject.Inject
@Suppress("LeakingThis")
@AndroidEntryPoint @AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
@ -26,27 +26,28 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
lateinit var settings: AppSettings lateinit var settings: AppSettings
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate(this) protected val insetsDelegate = WindowInsetsDelegate()
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
get() = listView get() = listView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
view.setBackgroundColor(view.context.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false listView.clipToPadding = false
insetsDelegate.onViewCreated(view) insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
} }
override fun onDestroyView() { override fun onDestroyView() {
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView() insetsDelegate.onDestroyView()
super.onDestroyView() super.onDestroyView()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (titleId != 0) { setTitle(if (titleId != 0) getString(titleId) else null)
setTitle(getString(titleId))
}
} }
@CallSuper @CallSuper
@ -56,9 +57,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
) )
} }
@Suppress("UsePropertyAccessSyntax") protected fun setTitle(title: CharSequence?) {
protected fun setTitle(title: CharSequence) { (activity as? SettingsActivity)?.setSectionTitle(title)
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
} }
} }

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -8,26 +7,34 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.core.util.ext.EventFlow
import org.koitharu.kotatsu.core.util.SingleLiveEvent import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
@JvmField @JvmField
protected val loadingCounter = CountedBooleanLiveData() protected val loadingCounter = MutableStateFlow(0)
@JvmField @JvmField
protected val errorEvent = SingleLiveEvent<Throwable>() protected val errorEvent = MutableEventFlow<Throwable>()
val onError: LiveData<Throwable> val onError: EventFlow<Throwable>
get() = errorEvent get() = errorEvent
val isLoading: LiveData<Boolean> val isLoading: StateFlow<Boolean>
get() = loadingCounter get() = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
protected fun launchJob( protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
@ -51,7 +58,11 @@ abstract class BaseViewModel : ViewModel() {
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug() throwable.printStackTraceDebug()
if (throwable !is CancellationException) { if (throwable !is CancellationException) {
errorEvent.postCall(throwable) errorEvent.call(throwable)
} }
} }
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
} }

@ -9,7 +9,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
abstract class CoroutineIntentService : BaseService() { abstract class CoroutineIntentService : BaseService() {

@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap
import android.media.ThumbnailUtils
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
class ThumbnailTransformation : Transformation {
override val cacheKey: String = javaClass.name
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
return ThumbnailUtils.extractThumbnail(
input,
size.width.pxOrElse { input.width },
size.height.pxOrElse { input.height },
)
}
override fun equals(other: Any?) = other is ThumbnailTransformation
override fun hashCode() = javaClass.hashCode()
}

@ -3,8 +3,10 @@ package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) : abstract class BoundsScrollListener(
RecyclerView.OnScrollListener() { @JvmField protected val offsetTop: Int,
@JvmField protected val offsetBottom: Int
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
@ -24,9 +26,16 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
if (firstVisibleItemPosition <= offsetTop) { if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView) onScrolledToStart(recyclerView)
} }
onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount)
} }
abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView) abstract fun onScrolledToEnd(recyclerView: RecyclerView)
protected open fun onPostScrolled(
recyclerView: RecyclerView,
firstVisibleItemPosition: Int,
visibleItemCount: Int
) = Unit
} }

@ -1,30 +0,0 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.RecyclerView
class ScrollListenerInvalidationObserver(
private val recyclerView: RecyclerView,
private val scrollListener: RecyclerView.OnScrollListener,
) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
invalidateScroll()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
invalidateScroll()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
invalidateScroll()
}
private fun invalidateScroll() {
recyclerView.post {
scrollListener.onScrolled(recyclerView, 0, 0)
}
}
}

@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -15,6 +16,12 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val fastScroller = FastScroller(context, attrs) val fastScroller = FastScroller(context, attrs)
var isFastScrollerEnabled: Boolean = true
set(value) {
field = value
fastScroller.isVisible = value && isVisible
}
init { init {
fastScroller.id = R.id.fast_scroller fastScroller.id = R.id.fast_scroller
fastScroller.layoutParams = ViewGroup.LayoutParams( fastScroller.layoutParams = ViewGroup.LayoutParams(
@ -30,7 +37,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
override fun setVisibility(visibility: Int) { override fun setVisibility(visibility: Int) {
super.setVisibility(visibility) super.setVisibility(visibility)
fastScroller.visibility = visibility fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
} }
override fun onAttachedToWindow() { override fun onAttachedToWindow() {

@ -9,6 +9,7 @@ import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.* import androidx.annotation.*
@ -24,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt import kotlin.math.roundToInt
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -56,6 +58,7 @@ class FastScroller @JvmOverloads constructor(
private var bubbleHeight = 0 private var bubbleHeight = 0
private var handleHeight = 0 private var handleHeight = 0
private var viewHeight = 0 private var viewHeight = 0
private var offset = 0
private var hideScrollbar = true private var hideScrollbar = true
private var showBubble = true private var showBubble = true
private var showBubbleAlways = false private var showBubbleAlways = false
@ -114,6 +117,9 @@ class FastScroller @JvmOverloads constructor(
return viewHeight * proportion return viewHeight * proportion
} }
val isScrollbarVisible: Boolean
get() = binding.scrollbar.isVisible
init { init {
clipChildren = false clipChildren = false
orientation = HORIZONTAL orientation = HORIZONTAL
@ -137,6 +143,7 @@ class FastScroller @JvmOverloads constructor(
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL)
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset)
} }
setTrackColor(trackColor) setTrackColor(trackColor)
@ -163,7 +170,9 @@ class FastScroller @JvmOverloads constructor(
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) {
return false
}
requestDisallowInterceptTouchEvent(true) requestDisallowInterceptTouchEvent(true)
setHandleSelected(true) setHandleSelected(true)
@ -248,7 +257,7 @@ class FastScroller @JvmOverloads constructor(
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply { layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
height = 0 height = 0
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
} }
@ -256,13 +265,13 @@ class FastScroller @JvmOverloads constructor(
height = LayoutParams.MATCH_PARENT height = LayoutParams.MATCH_PARENT
anchorGravity = GravityCompat.END anchorGravity = GravityCompat.END
anchorId = recyclerViewId anchorId = recyclerViewId
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply { is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
height = LayoutParams.MATCH_PARENT height = LayoutParams.MATCH_PARENT
gravity = GravityCompat.END gravity = GravityCompat.END
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply { is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
@ -270,7 +279,7 @@ class FastScroller @JvmOverloads constructor(
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
addRule(RelativeLayout.ALIGN_END, recyclerViewId) addRule(RelativeLayout.ALIGN_END, recyclerViewId)
setMargins(0, marginTop, 0, marginBottom) setMargins(offset, marginTop, offset, marginBottom)
} }
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
@ -294,10 +303,12 @@ class FastScroller @JvmOverloads constructor(
if (parent is ViewGroup) { if (parent is ViewGroup) {
setLayoutParams(parent as ViewGroup) setLayoutParams(parent as ViewGroup)
} else if (recyclerView.parent is ViewGroup) { } else {
val viewGroup = recyclerView.parent as ViewGroup val viewGroup = findValidParent(recyclerView)
viewGroup.addView(this) if (viewGroup != null) {
setLayoutParams(viewGroup) viewGroup.addView(this)
setLayoutParams(viewGroup)
}
} }
recyclerView.addOnScrollListener(scrollListener) recyclerView.addOnScrollListener(scrollListener)
@ -511,6 +522,14 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
} }
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup
} else {
null
}
}
private val BubbleSize.textSize private val BubbleSize.textSize
@Px get() = resources.getDimension(textSizeId) @Px get() = resources.getDimension(textSizeId)

@ -0,0 +1,123 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetBehavior
import com.google.android.material.sidesheet.SideSheetCallback
import com.google.android.material.sidesheet.SideSheetDialog
import java.util.LinkedList
sealed class AdaptiveSheetBehavior {
@JvmField
protected val callbacks = LinkedList<AdaptiveSheetCallback>()
abstract var state: Int
abstract var isDraggable: Boolean
open val isHideable: Boolean = true
fun addCallback(callback: AdaptiveSheetCallback) {
callbacks.add(callback)
}
fun removeCallback(callback: AdaptiveSheetCallback) {
callbacks.remove(callback)
}
class Bottom(
private val delegate: BottomSheetBehavior<*>,
) : AdaptiveSheetBehavior() {
override var state: Int
get() = delegate.state
set(value) {
delegate.state = value
}
override var isDraggable: Boolean
get() = delegate.isDraggable
set(value) {
delegate.isDraggable = value
}
override val isHideable: Boolean
get() = delegate.isHideable
var isFitToContents: Boolean
get() = delegate.isFitToContents
set(value) {
delegate.isFitToContents = value
}
init {
delegate.addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
callbacks.forEach { it.onStateChanged(bottomSheet, newState) }
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
callbacks.forEach { it.onSlide(bottomSheet, slideOffset) }
}
},
)
}
}
class Side(
private val delegate: SideSheetBehavior<*>,
) : AdaptiveSheetBehavior() {
override var state: Int
get() = delegate.state
set(value) {
delegate.state = value
}
override var isDraggable: Boolean
get() = delegate.isDraggable
set(value) {
delegate.isDraggable = value
}
init {
delegate.addCallback(
object : SideSheetCallback() {
override fun onStateChanged(sheet: View, newState: Int) {
callbacks.forEach { it.onStateChanged(sheet, newState) }
}
override fun onSlide(sheet: View, slideOffset: Float) {
callbacks.forEach { it.onSlide(sheet, slideOffset) }
}
},
)
}
}
companion object {
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) {
is BottomSheetDialog -> Bottom(dialog.behavior)
is SideSheetDialog -> Side(dialog.behavior)
else -> null
}
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) {
is BottomSheetBehavior<*> -> Bottom(behavior)
is SideSheetBehavior<*> -> Side(behavior)
else -> null
}
}
}

@ -0,0 +1,22 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.view.View
interface AdaptiveSheetCallback {
/**
* Called when the sheet changes its state.
*
* @param sheet The sheet view.
* @param newState The new state.
*/
fun onStateChanged(sheet: View, newState: Int)
/**
* Called when the sheet is being dragged.
*
* @param sheet The sheet view.
* @param slideOffset The new offset of this sheet.
*/
fun onSlide(sheet: View, slideOffset: Float) = Unit
}

@ -0,0 +1,94 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.isGone
import androidx.core.view.isVisible
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
class AdaptiveSheetHeaderBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
private var sheetBehavior: AdaptiveSheetBehavior? = null
var title: CharSequence?
get() = binding.shTextViewTitle.text
set(value) {
binding.shTextViewTitle.text = value
}
val isTitleVisible: Boolean
get() = binding.shLayoutSidesheet.isVisible
init {
orientation = VERTICAL
binding.shButtonClose.setOnClickListener { dismissSheet() }
context.withStyledAttributes(
attrs,
R.styleable.AdaptiveSheetHeaderBar, defStyleAttr,
) {
title = getText(R.styleable.AdaptiveSheetHeaderBar_title)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isInEditMode) {
val isTabled = resources.getBoolean(R.bool.is_tablet)
binding.shDragHandle.isGone = isTabled
binding.shLayoutSidesheet.isVisible = isTabled
} else {
setBottomSheetBehavior(findParentSheetBehavior())
}
}
override fun onDetachedFromWindow() {
setBottomSheetBehavior(null)
super.onDetachedFromWindow()
}
override fun onStateChanged(sheet: View, newState: Int) {
}
fun setTitle(@StringRes resId: Int) {
binding.shTextViewTitle.setText(resId)
}
private fun setBottomSheetBehavior(behavior: AdaptiveSheetBehavior?) {
binding.shDragHandle.isVisible = behavior is AdaptiveSheetBehavior.Bottom
binding.shLayoutSidesheet.isVisible = behavior is AdaptiveSheetBehavior.Side
sheetBehavior?.removeCallback(this)
sheetBehavior = behavior
behavior?.addCallback(this)
}
private fun dismissSheet() {
sheetBehavior?.state = AdaptiveSheetBehavior.STATE_HIDDEN
}
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
for (p in parents) {
val layoutParams = (p as? View)?.layoutParams
if (layoutParams is CoordinatorLayout.LayoutParams) {
AdaptiveSheetBehavior.from(layoutParams)?.let {
return it
}
}
}
return null
}
}

@ -0,0 +1,174 @@
package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.OnBackPressedDispatcher
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog
import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false
var viewBinding: B? = null
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
protected val behavior: AdaptiveSheetBehavior?
get() = AdaptiveSheetBehavior.from(dialog)
val isExpanded: Boolean
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = requireComponentDialog().onBackPressedDispatcher
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = onCreateViewBinding(inflater, container)
viewBinding = binding
return binding.root
}
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = requireViewBinding()
onViewBindingCreated(binding, savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
super.onDestroyView()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
return if (context.resources.getBoolean(R.bool.is_tablet)) {
SideSheetDialog(context, theme)
} else {
BottomSheetDialog(context, theme)
}
}
fun addSheetCallback(callback: AdaptiveSheetCallback) {
val b = behavior ?: return
b.addCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
?: dialog?.findViewById(materialR.id.coordinator)
if (rootView != null) {
callback.onStateChanged(rootView, b.state)
}
}
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
val b = behavior ?: return
if (isExpanded) {
b.state = BottomSheetBehavior.STATE_EXPANDED
}
if (b is AdaptiveSheetBehavior.Bottom) {
b.isFitToContents = !isFitToContentsDisabled && !isExpanded
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
rootView?.updateLayoutParams {
height = if (isFitToContentsDisabled || isExpanded) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
}
}
b.isDraggable = !isLocked
}
protected fun disableFitToContents() {
isFitToContentsDisabled = true
val b = behavior as? AdaptiveSheetBehavior.Bottom ?: return
b.isFitToContents = false
dialog?.findViewById<View>(materialR.id.design_bottom_sheet)?.updateLayoutParams {
height = LayoutParams.MATCH_PARENT
}
}
fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
}
override fun dismiss() {
if (!tryDismissWithAnimation(false)) {
super.dismiss()
}
}
override fun dismissAllowingStateLoss() {
if (!tryDismissWithAnimation(true)) {
super.dismissAllowingStateLoss()
}
}
/**
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
* false otherwise.
*/
private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean {
val shouldDismissWithAnimation = when (val dialog = dialog) {
is BottomSheetDialog -> dialog.dismissWithAnimation
is SideSheetDialog -> dialog.isDismissWithSheetAnimationEnabled
else -> false
}
val behavior = behavior ?: return false
return if (shouldDismissWithAnimation && behavior.isHideable) {
dismissWithAnimation(behavior, allowingStateLoss)
true
} else {
false
}
}
private fun dismissWithAnimation(behavior: AdaptiveSheetBehavior, allowingStateLoss: Boolean) {
waitingForDismissAllowingStateLoss = allowingStateLoss
if (behavior.state == AdaptiveSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation()
} else {
behavior.addCallback(SheetDismissCallback())
behavior.state = AdaptiveSheetBehavior.STATE_HIDDEN
}
}
private fun dismissAfterAnimation() {
if (waitingForDismissAllowingStateLoss) {
super.dismissAllowingStateLoss()
} else {
super.dismiss()
}
}
private inner class SheetDismissCallback : AdaptiveSheetCallback {
override fun onStateChanged(sheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation()
}
}
override fun onSlide(sheet: View, slideOffset: Float) {}
}
}

@ -14,7 +14,7 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
get() = activeActionMode != null get() = activeActionMode != null
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
activeActionMode?.finish() finishActionMode()
} }
fun onSupportActionModeStarted(mode: ActionMode) { fun onSupportActionModeStarted(mode: ActionMode) {
@ -45,6 +45,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener)) owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
} }
fun finishActionMode() {
activeActionMode?.finish()
}
private inner class ListenerLifecycleObserver( private inner class ListenerLifecycleObserver(
private val listener: ActionModeListener, private val listener: ActionModeListener,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {

@ -1,31 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData
import java.util.concurrent.atomic.AtomicInteger
class CountedBooleanLiveData : LiveData<Boolean>(false) {
private val counter = AtomicInteger(0)
@AnyThread
fun increment() {
if (counter.getAndIncrement() == 0) {
postValue(true)
}
}
@AnyThread
fun decrement() {
if (counter.decrementAndGet() == 0) {
postValue(false)
}
}
@AnyThread
fun reset() {
if (counter.getAndSet(0) != 0) {
postValue(false)
}
}
}

@ -1,18 +1,15 @@
package org.koitharu.kotatsu.core.ui.util package org.koitharu.kotatsu.core.ui.util
import android.view.View import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class ReversibleActionObserver( class ReversibleActionObserver(
private val snackbarHost: View, private val snackbarHost: View,
) : Observer<ReversibleAction?> { ) : FlowCollector<ReversibleAction> {
override fun onChanged(value: ReversibleAction?) { override suspend fun emit(value: ReversibleAction) {
if (value == null) {
return
}
val handle = value.handle val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length) val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)

@ -6,7 +6,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
fun interface ReversibleHandle { fun interface ReversibleHandle {

@ -5,10 +5,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import java.util.LinkedList
class WindowInsetsDelegate( class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField @JvmField
var handleImeInsets: Boolean = false var handleImeInsets: Boolean = false
@ -16,6 +15,7 @@ class WindowInsetsDelegate(
@JvmField @JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private val listeners = LinkedList<WindowInsetsListener>()
private var lastInsets: Insets? = null private var lastInsets: Insets? = null
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
@ -29,7 +29,7 @@ class WindowInsetsDelegate(
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()) handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
} }
if (newInsets != lastInsets) { if (newInsets != lastInsets) {
listener.onWindowInsetsChanged(newInsets) listeners.forEach { it.onWindowInsetsChanged(newInsets) }
lastInsets = newInsets lastInsets = newInsets
} }
return handledInsets return handledInsets
@ -52,6 +52,15 @@ class WindowInsetsDelegate(
} }
} }
fun addInsetsListener(listener: WindowInsetsListener) {
listeners.add(listener)
lastInsets?.let { listener.onWindowInsetsChanged(it) }
}
fun removeInsetsListener(listener: WindowInsetsListener) {
listeners.remove(listener)
}
fun onViewCreated(view: View) { fun onViewCreated(view: View) {
ViewCompat.setOnApplyWindowInsetsListener(view, this) ViewCompat.setOnApplyWindowInsetsListener(view, this)
view.addOnLayoutChangeListener(this) view.addOnLayoutChangeListener(this)

@ -30,6 +30,7 @@ import com.google.android.material.R as materialR
private const val THROTTLE_DELAY = 200L private const val THROTTLE_DELAY = 200L
@Deprecated("")
class BottomSheetHeaderBar @JvmOverloads constructor( class BottomSheetHeaderBar @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,

@ -6,6 +6,7 @@ import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow import androidx.core.content.res.getColorStateListOrThrow
import androidx.core.view.children import androidx.core.view.children
@ -101,6 +102,13 @@ class ChipsView @JvmOverloads constructor(
chip.setTextColor(tint ?: defaultChipTextColor) chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable chip.isCheckable = model.isCheckable
if (model.icon == 0) {
chip.chipIcon = null
chip.isChipIconVisible = false
} else {
chip.setChipIconResource(model.icon)
chip.isChipIconVisible = true
}
chip.isChecked = model.isChecked chip.isChecked = model.isChecked
chip.tag = model.data chip.tag = model.data
} }
@ -134,6 +142,7 @@ class ChipsView @JvmOverloads constructor(
class ChipModel( class ChipModel(
@ColorRes val tint: Int, @ColorRes val tint: Int,
val title: CharSequence, val title: CharSequence,
@DrawableRes val icon: Int,
val isCheckable: Boolean, val isCheckable: Boolean,
val isChecked: Boolean, val isChecked: Boolean,
val data: Any? = null, val data: Any? = null,
@ -147,6 +156,7 @@ class ChipsView @JvmOverloads constructor(
if (tint != other.tint) return false if (tint != other.tint) return false
if (title != other.title) return false if (title != other.title) return false
if (icon != other.icon) return false
if (isCheckable != other.isCheckable) return false if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false if (isChecked != other.isChecked) return false
return data == other.data return data == other.data
@ -155,6 +165,7 @@ class ChipsView @JvmOverloads constructor(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = tint.hashCode() var result = tint.hashCode()
result = 31 * result + title.hashCode() result = 31 * result + title.hashCode()
result = 31 * result + icon.hashCode()
result = 31 * result + isCheckable.hashCode() result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode() result = 31 * result + isChecked.hashCode()
result = 31 * result + (data?.hashCode() ?: 0) result = 31 * result + (data?.hashCode() ?: 0)

@ -92,6 +92,14 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
) )
} }
fun showOrHide(show: Boolean) {
if (show) {
show()
} else {
hide()
}
}
private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
currentAnimator = animate() currentAnimator = animate()
.translationY(targetY) .translationY(targetY)

@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.util
import android.app.Activity
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
import org.acra.ACRA
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks {
private val timeFormat = SimpleDateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.ROOT)
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
ACRA.errorReporter.putCustomData(f.key(), "${time()}: ${f.arguments}")
}
override fun onFragmentDetached(fm: FragmentManager, f: Fragment) {
super.onFragmentDetached(fm, f)
ACRA.errorReporter.removeCustomData(f.key())
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
super.onActivityCreated(activity, savedInstanceState)
ACRA.errorReporter.putCustomData(activity.key(), "${time()}: ${activity.intent}")
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true)
}
override fun onActivityDestroyed(activity: Activity) {
super.onActivityDestroyed(activity)
ACRA.errorReporter.removeCustomData(activity.key())
}
private fun Activity.key() = "Activity[${javaClass.simpleName}]"
private fun Fragment.key() = "Fragment[${javaClass.simpleName}]"
private fun time() = timeFormat.format(Date())
}

@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.util
class CompositeRunnable(
private val children: List<Runnable>,
) : Runnable, Collection<Runnable> by children {
override fun run() {
for (child in children) {
child.run()
}
}
}

@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.util
import kotlinx.coroutines.flow.FlowCollector
class Event<T>(
private val data: T,
) {
private var isConsumed = false
suspend fun consume(collector: FlowCollector<T>) {
if (!isConsumed) {
collector.emit(data)
isConsumed = true
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Event<*>
if (data != other.data) return false
return isConsumed == other.isConsumed
}
override fun hashCode(): Int {
var result = data?.hashCode() ?: 0
result = 31 * result + isConsumed.hashCode()
return result
}
override fun toString(): String {
return "Event(data=$data, isConsumed=$isConsumed)"
}
}

@ -1,86 +0,0 @@
package org.koitharu.kotatsu.core.util
import androidx.lifecycle.LiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
private const val DEFAULT_TIMEOUT = 5_000L
/**
* Similar to a CoroutineLiveData but optimized for using within infinite flows
*/
class FlowLiveData<T>(
private val flow: Flow<T>,
defaultValue: T,
context: CoroutineContext = EmptyCoroutineContext,
private val timeoutInMs: Long = DEFAULT_TIMEOUT,
) : LiveData<T>(defaultValue) {
private val scope = CoroutineScope(Dispatchers.Main.immediate + context + SupervisorJob(context[Job]))
private var job: Job? = null
private var cancellationJob: Job? = null
override fun onActive() {
super.onActive()
cancellationJob?.cancel()
cancellationJob = null
if (job?.isActive == true) {
return
}
job = scope.launch {
flow.collect(Collector())
}
}
override fun onInactive() {
super.onInactive()
cancellationJob?.cancel()
cancellationJob = scope.launch(Dispatchers.Main.immediate) {
delay(timeoutInMs)
if (!hasActiveObservers()) {
job?.cancel()
job = null
}
}
}
private inner class Collector : FlowCollector<T> {
private var previousValue: Any? = value
private val dispatcher = Dispatchers.Main.immediate
override suspend fun emit(value: T) {
if (previousValue != value) {
previousValue = value
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
setValue(value)
}
} else {
setValue(value)
}
}
}
}
}
fun <T> Flow<T>.asFlowLiveData(
context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T,
timeoutInMs: Long = DEFAULT_TIMEOUT,
): LiveData<T> = FlowLiveData(this, defaultValue, context, timeoutInMs)
fun <T> StateFlow<T>.asFlowLiveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
): LiveData<T> = FlowLiveData(this, value, context, timeoutInMs)

@ -8,7 +8,7 @@ import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class RetainedLifecycleCoroutineScope( class RetainedLifecycleCoroutineScope(
private val lifecycle: RetainedLifecycle, val lifecycle: RetainedLifecycle,
) : CoroutineScope, RetainedLifecycle.OnClearedListener { ) : CoroutineScope, RetainedLifecycle.OnClearedListener {
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate

@ -1,50 +0,0 @@
package org.koitharu.kotatsu.core.util
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.EmptyCoroutineContext
class SingleLiveEvent<T> : LiveData<T>() {
private val pending = AtomicBoolean(false)
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner) {
if (pending.compareAndSet(true, false)) {
observer.onChanged(it)
}
}
}
override fun setValue(value: T) {
pending.set(true)
super.setValue(value)
}
@MainThread
fun call(newValue: T) {
setValue(newValue)
}
@AnyThread
fun postCall(newValue: T) {
postValue(newValue)
}
suspend fun emitCall(newValue: T) {
val dispatcher = Dispatchers.Main.immediate
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
setValue(newValue)
}
} else {
setValue(newValue)
}
}
}

@ -5,7 +5,8 @@ import android.app.Activity
class TaggedActivityResult( class TaggedActivityResult(
val tag: String, val tag: String,
val result: Int, val result: Int,
) ) {
val TaggedActivityResult.isSuccess: Boolean val isSuccess: Boolean
get() = this.result == Activity.RESULT_OK get() = result == Activity.RESULT_OK
}

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.app.Activity import android.app.Activity
import android.app.ActivityManager import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions import android.app.ActivityOptions
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE import android.content.Context.ACTIVITY_SERVICE
@ -15,6 +16,7 @@ import android.database.SQLException
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
import android.view.ViewPropertyAnimator import android.view.ViewPropertyAnimator
@ -42,7 +44,6 @@ import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -140,13 +141,24 @@ fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false return activityManager?.isLowRamDevice ?: false
} }
fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.makeScaleUpAnimation( val Context.ramAvailable: Long
view, get() {
0, val result = MemoryInfo()
0, activityManager?.getMemoryInfo(result)
view.width, return result.availMem
view.height, }
)
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
).toBundle()
} else {
null
}
fun Resources.getLocalesConfig(): LocaleListCompat { fun Resources.getLocalesConfig(): LocaleListCompat {
val tagsList = StringJoiner(",") val tagsList = StringJoiner(",")

@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.util.ext
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
callback(isExpended)
addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
if (expanded != isExpended) {
isExpended = expanded
callback(expanded)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
},
)
}

@ -8,9 +8,11 @@ import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -18,6 +20,9 @@ import kotlin.coroutines.resumeWithException
val processLifecycleScope: LifecycleCoroutineScope val processLifecycleScope: LifecycleCoroutineScope
inline get() = ProcessLifecycleOwner.get().lifecycleScope inline get() = ProcessLifecycleOwner.get().lifecycleScope
val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope
inline get() = RetainedLifecycleCoroutineScope(this)
suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) { suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) {
if (currentState.isAtLeast(state)) { if (currentState.isAtLeast(state)) {
return return

@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.Event
@Suppress("FunctionName")
fun <T> MutableEventFlow() = MutableStateFlow<Event<T>?>(null)
typealias EventFlow<T> = StateFlow<Event<T>?>
typealias MutableEventFlow<T> = MutableStateFlow<Event<T>?>
@AnyThread
fun <T> MutableEventFlow<T>.call(data: T) {
value = Event(data)
}

@ -26,6 +26,8 @@ fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText() it.readText()
} }

@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import org.koitharu.kotatsu.R
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> { fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true var isFirstCall = true
@ -52,3 +53,12 @@ fun <T> Flow<Collection<T>>.flatten(): Flow<T> = flow {
} }
} }
} }
fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
var previous: T? = null
collect { value ->
val result = previous to value
previous = value
emit(result)
}
}

@ -0,0 +1,29 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.util.Event
fun <T> Flow<T>.observe(owner: LifecycleOwner, collector: FlowCollector<T>) {
val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
owner.lifecycleScope.launch(start = start) {
collect(collector)
}
}
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
owner.lifecycleScope.launch {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect {
it?.consume(collector)
}
}
}
}

@ -52,3 +52,11 @@ suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner {
} }
} }
} }
fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
return
}
show(fm, tag)
}

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
@ -23,3 +24,17 @@ fun Response.parseJsonOrNull(): JSONObject? {
closeQuietly() closeQuietly()
} }
} }
val HttpUrl.isHttpOrHttps: Boolean
get() {
val s = scheme.lowercase()
return s == "https" || s == "http"
}
fun Response.ensureSuccess() = apply {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
val message = "Invalid response: $code $message at ${request.url}"
closeQuietly()
throw IllegalStateException(message)
}
}

@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.BufferedObserver
import kotlin.coroutines.EmptyCoroutineContext
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
"LiveData value is null"
}
fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver<T>) {
var previous: T? = null
this.observe(owner) {
observer.onChanged(it, previous)
previous = it
}
}
suspend fun <T> MutableLiveData<T>.emitValue(newValue: T) {
val dispatcher = Dispatchers.Main.immediate
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
value = newValue
}
} else {
value = newValue
}
}

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import org.koitharu.kotatsu.core.util.CompositeRunnable
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? { fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) { if (obj == null || !isInstance(obj)) {
@ -7,3 +9,15 @@ fun <T> Class<T>.castOrNull(obj: Any?): T? {
} }
return obj as T return obj as T
} }
/* CompositeRunnable */
operator fun Runnable.plus(other: Runnable): Runnable {
val list = ArrayList<Runnable>(this.size + other.size)
if (this is CompositeRunnable) list.addAll(this) else list.add(this)
if (other is CompositeRunnable) list.addAll(other) else list.add(other)
return CompositeRunnable(list)
}
private val Runnable.size: Int
get() = if (this is CompositeRunnable) size else 1

@ -2,10 +2,9 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.util.UUID import java.util.UUID
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { inline fun <C : CharSequence> C?.ifNullOrEmpty(defaultValue: () -> C): C {
return if (this.isNullOrEmpty()) defaultValue() else this return if (this.isNullOrEmpty()) defaultValue() else this
} }
@ -35,3 +34,9 @@ fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float)
val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f)
return diff < threshold return diff < threshold
} }
fun CharSequence.sanitize(): CharSequence {
return filterNot { c -> c.isReplacement() }
}
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'

@ -5,6 +5,7 @@ import android.graphics.Color
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
@ -22,6 +23,22 @@ fun Context.getThemeColor(
it.getColor(0, fallback) it.getColor(0, fallback)
} }
@Px
fun Context.getThemeDimensionPixelSize(
@AttrRes resId: Int,
@ColorInt fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelSize(0, fallback)
}
@Px
fun Context.getThemeDimensionPixelOffset(
@AttrRes resId: Int,
@ColorInt fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelOffset(0, fallback)
}
@ColorInt @ColorInt
fun Context.getThemeColor( fun Context.getThemeColor(
@AttrRes resId: Int, @AttrRes resId: Int,

@ -87,3 +87,6 @@ fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) || return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
cause?.isWebViewUnavailable() == true cause?.isWebViewUnavailable() == true
} }
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)

@ -1,10 +1,12 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.createViewModelLazy import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
@MainThread @MainThread
@ -17,3 +19,7 @@ inline fun <reified VM : ViewModel> Fragment.parentFragmentViewModels(
extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras }, extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras },
factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory }, factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory },
) )
val ViewModelStore.values: Collection<ViewModel>
@SuppressLint("RestrictedApi")
get() = this.keys().mapNotNull { get(it) }

@ -0,0 +1,80 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@Deprecated("")
class DetailsInteractor @Inject constructor(
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
fun observeIsFavourite(mangaId: Long): Flow<Boolean> {
return favouritesRepository.observeCategoriesIds(mangaId)
.map { it.isNotEmpty() }
}
fun observeNewChapters(mangaId: Long): Flow<Int> {
return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(mangaId)
} else {
flowOf(0)
}
}
}
fun observeScrobblingInfo(mangaId: Long): Flow<List<ScrobblingInfo>> {
return combine(
scrobblers.map { it.observeScrobblingInfo(mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}
}
fun observeIncognitoMode(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow
.distinctUntilChangedBy { it?.isNsfw }
.flatMapLatest { manga ->
if (manga != null) {
historyRepository.observeShouldSkip(manga)
} else {
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
}
}
}
suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? {
return if (subject?.any?.id == localManga.manga.id) {
subject.copy(
localManga = runCatchingCancellable {
localMangaRepository.getDetails(localManga.manga)
},
)
} else {
subject
}
}
}

@ -0,0 +1,65 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.model.DoubleManga
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
class DoubleMangaLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga(
remoteManga = remoteDeferred.await(),
localManga = localDeferred.await(),
)
}
suspend operator fun invoke(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return invoke(manga)
}
suspend operator fun invoke(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return invoke(manga)
}
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
if (manga.isLocal) {
localMangaRepository.getDetails(manga)
} else {
localMangaRepository.findSavedManga(manga)?.manga
} ?: return null
}
}
private suspend fun loadRemote(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga)
} else {
manga
} ?: return null
val repository = mangaRepositoryFactory.create(seed.source)
repository.getDetails(seed)
}
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

@ -0,0 +1,76 @@
package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.data.filterChapters
data class DoubleManga(
private val remoteManga: Result<Manga>?,
private val localManga: Result<Manga>?,
) {
constructor(manga: Manga) : this(
remoteManga = if (manga.source != MangaSource.LOCAL) Result.success(manga) else null,
localManga = if (manga.source == MangaSource.LOCAL) Result.success(manga) else null,
)
val remote: Manga?
get() = remoteManga?.getOrNull()
val local: Manga?
get() = localManga?.getOrNull()
val any: Manga?
get() = remote ?: local
val hasRemote: Boolean
get() = remoteManga?.isSuccess == true
val hasLocal: Boolean
get() = localManga?.isSuccess == true
val chapters: List<MangaChapter>? by lazy(LazyThreadSafetyMode.PUBLICATION) {
mergeChapters()
}
fun requireAny(): Manga {
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
if (result != null) {
return result
}
throw (
remoteManga?.exceptionOrNull()
?: localManga?.exceptionOrNull()
?: IllegalStateException("No online either local manga available")
)
}
fun filterChapters(branch: String?) = DoubleManga(
remoteManga?.map { it.filterChapters(branch) },
localManga?.map { it.filterChapters(branch) },
)
private fun mergeChapters(): List<MangaChapter>? {
val remoteChapters = remote?.chapters
val localChapters = local?.chapters
if (localChapters == null && remoteChapters == null) {
return null
}
val localMap = if (!localChapters.isNullOrEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(maxOf(remoteChapters?.size ?: 0, localChapters?.size ?: 0))
remoteChapters?.forEach { r ->
localMap?.remove(r.id)?.let { l ->
result.add(l)
} ?: result.add(r)
}
localMap?.values?.let {
result.addAll(it)
}
return result
}
}

@ -10,12 +10,12 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -116,7 +116,7 @@ class MangaPrefetchService : CoroutineIntentService() {
return false return false
} }
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled() return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }
} }
} }

@ -0,0 +1,92 @@
package org.koitharu.kotatsu.details.ui
import android.transition.TransitionManager
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.databinding.ItemTipBinding
import com.google.android.material.R as materialR
class ButtonTip(
private val root: ViewGroup,
private val insetsDelegate: WindowInsetsDelegate,
private val viewModel: DetailsViewModel,
) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener {
private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false)
private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize)
init {
selfBinding.textView.setText(R.string.details_button_tip)
selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap)
selfBinding.root.id = R.id.layout_tip
selfBinding.buttonClose.setOnClickListener(this)
}
override fun onClick(v: View?) {
remove()
}
override fun onWindowInsetsChanged(insets: Insets) {
if (root is CoordinatorLayout) {
selfBinding.root.updateLayoutParams<CoordinatorLayout.LayoutParams> {
bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize
}
}
}
fun addToRoot() {
val lp: ViewGroup.LayoutParams = when (root) {
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
// anchorId = R.id.layout_bottom
// anchorGravity = Gravity.TOP
gravity = Gravity.BOTTOM
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
bottomMargin += actionBarSize
}
is ConstraintLayout -> ConstraintLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width)
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
}
else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
root.addView(selfBinding.root, lp)
if (root is ConstraintLayout) {
val cs = ConstraintSet()
cs.clone(root)
cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM)
cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START)
cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END)
cs.applyTo(root)
}
insetsDelegate.addInsetsListener(this)
}
fun remove() {
if (root.context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(root)
}
insetsDelegate.removeInsetsListener(this)
root.removeView(selfBinding.root)
viewModel.onButtonTipClosed()
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save