Merge branch 'devel' into feature/kitsu

# Conflicts:
#	app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
#	app/src/main/res/values/strings.xml
This commit is contained in:
Zakhar Timoshenko
2023-05-29 17:29:05 +03:00
921 changed files with 14609 additions and 12719 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 540
versionName '5.0.2'
versionCode 546
versionName '5.1.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -42,6 +42,7 @@ android {
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
main.java.srcDirs += 'src/main/kotlin/'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -59,7 +60,7 @@ android {
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
}
testOptions {
unitTests.includeAndroidResources true
@@ -78,15 +79,15 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:96b9ac36f3') {
implementation('com.github.KotatsuApp:kotatsu-parsers:ebcc6391d6') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.1'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
@@ -98,12 +99,19 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:31.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
@@ -115,8 +123,8 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.45'
kapt 'com.google.dagger:hilt-compiler:2.45'
implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.46.1'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
@@ -133,18 +141,18 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.45'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
}

View File

@@ -1,4 +1,5 @@
-optimizationpasses 8
-dontobfuscate
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkExpressionValueIsNotNull(...);
public static void checkNotNullExpressionValue(...);
@@ -7,7 +8,7 @@
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**

View File

@@ -8,7 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -19,7 +18,8 @@ import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.awaitForIdle
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
@RunWith(AndroidJUnit4::class)

View File

@@ -5,8 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
@@ -19,7 +17,9 @@ import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags
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 javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@@ -52,6 +52,7 @@ class AppBackupAgentTest {
title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
)
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate(

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import junit.framework.TestCase.*
import kotlinx.coroutines.test.runTest
import org.junit.Before
@@ -11,8 +10,9 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.util
import android.util.Log
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class LoggingAdapterDataObserver(
private val tag: String,
) : AdapterDataObserver() {
override fun onChanged() {
Log.d(tag, "onChanged()")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)")
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)")
}
override fun onStateRestorationPolicyChanged() {
Log.d(tag, "onStateRestorationPolicyChanged()")
}
}

View File

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

View File

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

View File

@@ -95,6 +95,7 @@
<data android:scheme="kotatsu" />
<data android:host="about" />
<data android:host="sync-settings" />
</intent-filter>
</activity>
<activity
@@ -128,7 +129,7 @@
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:name="org.koitharu.kotatsu.download.ui.list.DownloadsActivity"
android:label="@string/downloads"
android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
@@ -174,10 +175,6 @@
</intent-filter>
</activity>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
@@ -252,7 +249,13 @@
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"

View File

@@ -1,5 +0,0 @@
package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LifecycleService
abstract class BaseService : LifecycleService()

View File

@@ -1,31 +0,0 @@
package org.koitharu.kotatsu.base.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)
}
}
}

View File

@@ -1,5 +0,0 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize)

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.details.domain
class BranchComparator : Comparator<String?> {
override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2)
}

View File

@@ -1,340 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
) : BaseViewModel() {
private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>()
private val history = historyRepository.observeOne(delegate.mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(delegate.mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga,
delegate.selectedBranch,
history,
historyRepository.observeShouldSkip(delegate.manga),
) { m, b, h, im ->
HistoryInfo(m, b, h, im)
}.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, null, false),
)
val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = combine(
delegate.manga,
delegate.relatedManga,
) { m1, m2 ->
val url = when {
m1?.source == MangaSource.LOCAL -> m1.url
m2?.source == MangaSource.LOCAL -> m2.url
else -> null
}
if (url != null) {
val file = url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0)
val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest {
val description = it?.description
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable }
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = combine(
scrobblers.map { it.observeScrobblingInfo(delegate.mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<String?>> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList()
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchIndex = combine(
branches.asFlow(),
delegate.selectedBranch,
) { branches, selected ->
branches.indexOf(selected)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, -1)
val selectedBranchName = delegate.selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null)
val isChaptersEmpty: LiveData<Boolean> = combine(
delegate.manga,
isLoading.asFlow(),
) { m, loading ->
m != null && m.chapters.isNullOrEmpty() && !loading
}.asFlowLiveData(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
delegate.manga,
delegate.relatedManga,
history,
delegate.selectedBranch,
newChapters,
) { manga, related, history, branch, news ->
delegate.mapChapters(manga, related, history, news, branch)
},
chaptersReversed,
chaptersQuery,
) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String?
get() = delegate.selectedBranch.value
init {
loadingJob = doLoad()
launchJob(Dispatchers.Default) {
localStorageChanges
.collect { onDownloadComplete(it) }
}
}
fun reload() {
loadingJob.cancel()
loadingJob = doLoad()
}
fun deleteLocal() {
val m = delegate.manga.value
if (m == null) {
onShowToast.call(R.string.file_not_found)
return
}
launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
onMangaRemoved.emitCall(manga)
}
}
fun removeBookmark(bookmark: Bookmark) {
launchJob {
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
onShowToast.call(R.string.bookmark_removed)
}
}
fun setChaptersReversed(newValue: Boolean) {
settings.chaptersReverse = newValue
}
fun setSelectedBranch(branch: String?) {
delegate.selectedBranch.value = branch
}
fun getRemoteManga(): Manga? {
return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
}
fun performChapterSearch(query: String?) {
chaptersQuery.value = query?.trim().orEmpty()
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
fun unregisterScrobbling(index: Int) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
)
}
}
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value)
val chapters = checkNotNull(manga.getChapters(selectedBranchValue))
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
if (query.isEmpty() || this.isEmpty()) {
return this
}
return filter {
it.chapter.name.contains(query, ignoreCase = true)
}
}
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.manga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable.trim()
}
private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value?.getOrNull(index)
val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
} else {
null
}
if (scrobbler == null) {
errorEvent.call(IllegalStateException("Scrobbler [$index] is not available"))
}
return scrobbler
}
}

View File

@@ -1,162 +0,0 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
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.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
@ViewModelScoped
class MangaDetailsDelegate @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private val intent = MangaIntent(savedStateHandle)
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null)
// Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow<Manga?>(null)
val manga: StateFlow<Manga?>
get() = mangaData
val mangaId = intent.manga?.id ?: intent.mangaId
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
mangaData.value = manga
manga = mangaRepositoryFactory.create(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
mangaData.value = manga
relatedManga.value = runCatchingCancellable {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)?.manga
}
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
fun mapChapters(
manga: Manga?,
related: Manga?,
history: MangaHistory?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chapters = manga?.chapters ?: return emptyList()
val relatedChapters = related?.chapters
return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch)
} else {
mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch)
}
}
private fun mapChapters(
chapters: List<MangaChapter>,
downloadedChapters: List<MangaChapter>?,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapTo(HashSet(downloadedChapters.size)) { it.id }
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
continue
}
result += chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true,
)
}
if (result.size < chapters.size / 2) {
result.trimToSize()
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = false,
isDownloaded = false,
) ?: chapter.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
isNew = i >= firstNewIndex,
isMissing = true,
isDownloaded = false,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}
if (result.size < sourceChapters.size / 2) {
result.trimToSize()
}
return result
}
}

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.replaceWith
class BranchesAdapter : BaseAdapter() {
private val dataSet = ArrayList<String?>()
override fun getCount(): Int {
return dataSet.size
}
override fun getItem(position: Int): Any? {
return dataSet[position]
}
override fun getItemId(position: Int): Long {
return dataSet[position].hashCode().toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch, parent, false)
(view as TextView).text = dataSet[position]
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch_dropdown, parent, false)
(view as TextView).text = dataSet[position]
return view
}
fun setItems(items: Collection<String?>) {
dataSet.replaceWith(items)
notifyDataSetChanged()
}
}

View File

@@ -1,56 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun chapterListItemAD(
clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.number.toString()
binding.textViewDescription.textAndVisible = item.description()
}
when (item.status) {
FLAG_UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
binding.textViewNumber.setTextColor(context.getThemeColor(com.google.android.material.R.attr.colorOnTertiary))
}
FLAG_CURRENT -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
else -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
}
}
val isMissing = item.hasFlag(FLAG_MISSING)
binding.textViewTitle.alpha = if (isMissing) 0.3f else 1f
binding.textViewDescription.alpha = if (isMissing) 0.3f else 1f
binding.textViewNumber.alpha = if (isMissing) 0.3f else 1f
binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED)
binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW)
}
}

View File

@@ -1,281 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.app.Service
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ServiceScoped
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
import javax.inject.Inject
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 150L
@ServiceScoped
class DownloadManager @Inject constructor(
service: Service,
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) {
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
private val coroutineScope = (service as LifecycleService).lifecycleScope
fun downloadManga(
manga: Manga,
chaptersIds: LongArray?,
startId: Int,
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null),
)
val pausingHandle = PausingHandle()
val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle)
}
private suspend fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int,
) {
@Suppress("NAME_SHADOWING")
var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
withMangaLock(manga) {
semaphore.withPermit {
outState.value = DownloadState.Preparing(startId, manga, null)
val destination = localMangaRepository.getOutputDir(manga)
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
var output: LocalMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, data)
val coverUrl = data.largeCoverUrl.ifNullOrEmpty { data.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache.get(url)
?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
if (output.flushChapter(chapter)) {
runCatchingCancellable {
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug)
}
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga)
outState.value = DownloadState.Done(startId, data, cover, localManga.manga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val state = outState.value
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
outState.value = state
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
private suspend fun downloadFile(
url: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get()
.build()
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyToSuspending(out)
}
return file
}
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
canRetry = false,
)
}
private suspend fun loadCover(manga: Manga) = runCatchingCancellable {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.allowHardware(false)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),
).drawable
}.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
}

View File

@@ -1,234 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.graphics.drawable.Drawable
import org.koitharu.kotatsu.parsers.model.Manga
sealed interface DownloadState {
val startId: Int
val manga: Manga
val cover: Drawable?
override fun equals(other: Any?): Boolean
override fun hashCode(): Int
val isTerminal: Boolean
get() = this is Done || this is Cancelled || (this is Error && !canRetry)
class Queued(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Queued
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Preparing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Preparing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Progress(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
) : DownloadState {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Progress
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
return result
}
}
class Done(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val localManga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Done
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (localManga != other.localManga) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + localManga.hashCode()
return result
}
}
class Error(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
val canRetry: Boolean,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result
}
}
class Cancelled(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cancelled
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class PostProcessing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostProcessing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
}

View File

@@ -1,140 +0,0 @@
package org.koitharu.kotatsu.download.ui
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst
import org.koitharu.kotatsu.utils.ext.source
fun downloadItemAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) {
var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_cancel -> item.cancel()
R.id.button_resume -> item.resume()
else -> context.startActivity(
DetailsActivity.newIntent(context, item.progressValue.manga),
)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(lifecycleOwner, state.manga.coverUrl)?.run {
placeholder(state.cover)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
source(state.manga.source)
allowRgb565(true)
enqueueWith(coil)
}
}.onEach { state ->
binding.textViewTitle.text = state.manga.title
when (state) {
is DownloadState.Cancelled -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
binding.buttonCancel.isVisible = state.canRetry
binding.buttonResume.isVisible = state.canRetry
}
is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
}
}.launchIn(lifecycleOwner.lifecycleScope)
}
onViewRecycled {
job?.cancel()
job = null
}
}

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.download.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import javax.inject.Inject
@AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@Inject
lateinit var coil: ImageLoader
private lateinit var serviceConnection: DownloadsConnection
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(this, coil)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
serviceConnection = DownloadsConnection(this, this)
serviceConnection.items.observe(this) { items ->
adapter.items = items
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
serviceConnection.bind()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.download.ui
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
typealias DownloadItem = PausingProgressJob<DownloadState>
class DownloadsAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<DownloadItem>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(lifecycleOwner, coil))
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return items[position].progressValue.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<DownloadItem>() {
override fun areItemsTheSame(
oldItem: DownloadItem,
newItem: DownloadItem,
): Boolean {
return oldItem.progressValue.startId == newItem.progressValue.startId
}
override fun areContentsTheSame(
oldItem: DownloadItem,
newItem: DownloadItem,
): Boolean {
return oldItem.progressValue == newItem.progressValue && oldItem.isPaused == newItem.isPaused
}
override fun getChangePayload(oldItem: DownloadItem, newItem: DownloadItem): Any {
return Unit
}
}
}

View File

@@ -1,76 +0,0 @@
package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
class DownloadsConnection(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
) : ServiceConnection {
private var bindingObserver: BindingLifecycleObserver? = null
private var collectJob: Job? = null
private val itemsFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
val items
get() = itemsFlow.asFlowLiveData()
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleOwner.lifecycleScope.launch {
binder.downloads.collect {
itemsFlow.value = it
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
itemsFlow.value = itemsFlow.value.filter { it.progressValue.isTerminal }
}
fun bind() {
if (bindingObserver != null) {
return
}
bindingObserver = BindingLifecycleObserver().also {
lifecycleOwner.lifecycle.addObserver(it)
}
context.bindService(Intent(context, DownloadService::class.java), this, 0)
}
fun unbind() {
bindingObserver?.let {
lifecycleOwner.lifecycle.removeObserver(it)
}
bindingObserver = null
context.unbindService(this)
}
private inner class BindingLifecycleObserver : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unbind()
}
}
}

View File

@@ -1,356 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.core.util.forEach
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context) {
private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val queueIntent = PendingIntentCompat.getActivity(
context,
REQUEST_QUEUE,
DownloadsActivity.newIntent(context),
0,
false,
)
private val localListIntent = PendingIntentCompat.getActivity(
context,
REQUEST_LIST_LOCAL,
MangaListActivity.newIntent(context, MangaSource.LOCAL),
0,
false,
)
init {
groupBuilder.setOnlyAlertOnce(true)
groupBuilder.setDefaults(0)
groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
}
fun buildGroupNotification(): Notification {
val style = NotificationCompat.InboxStyle(groupBuilder)
var progress = 0f
var isAllDone = true
var isInProgress = false
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
states.forEach { _, state ->
if (state.manga.isNsfw) {
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
}
val summary = when (state) {
is DownloadState.Cancelled -> {
progress++
context.getString(R.string.cancelling_)
}
is DownloadState.Done -> {
progress++
context.getString(R.string.download_complete)
}
is DownloadState.Error -> {
isAllDone = false
context.getString(R.string.error)
}
is DownloadState.PostProcessing -> {
progress++
isInProgress = true
isAllDone = false
context.getString(R.string.processing_)
}
is DownloadState.Preparing -> {
isAllDone = false
isInProgress = true
context.getString(R.string.preparing_)
}
is DownloadState.Progress -> {
isAllDone = false
isInProgress = true
progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
}
is DownloadState.Queued -> {
isAllDone = false
isInProgress = true
context.getString(R.string.queued)
}
}
style.addLine(
context.getString(
R.string.download_summary_pattern,
state.manga.title.ellipsize(16).htmlEncode(),
summary.htmlEncode(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
)
}
progress = if (isInProgress) {
progress / states.size.toFloat()
} else {
1f
}
style.setBigContentTitle(
context.getString(if (isAllDone) R.string.download_complete else R.string.downloading_manga),
)
groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size()))
groupBuilder.setNumber(states.size)
groupBuilder.setSmallIcon(
if (isInProgress) android.R.drawable.stat_sys_download else android.R.drawable.stat_sys_download_done,
)
groupBuilder.setContentIntent(if (isAllDone) localListIntent else queueIntent)
groupBuilder.setAutoCancel(isAllDone)
when (progress) {
1f -> groupBuilder.setProgress(0, 0, false)
0f -> groupBuilder.setProgress(1, 0, true)
else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false)
}
return groupBuilder.build()
}
fun detach() {
if (states.isNotEmpty()) {
val notification = buildGroupNotification()
manager.notify(ID_GROUP_DETACHED, notification)
}
manager.cancel(ID_GROUP)
}
fun newItem(startId: Int) = Item(startId)
inner class Item(
private val startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
PendingIntentCompat.getBroadcast(
context,
startId * 2,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
),
)
private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black,
context.getString(R.string.try_again),
PendingIntentCompat.getBroadcast(
context,
startId * 2 + 1,
DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
),
)
init {
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
fun notify(state: DownloadState, timeLeft: Long) {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(queueIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
when (state) {
is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(!state.canRetry)
builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
builder.setSubText(percent)
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
}
val notification = builder.build()
states.append(startId, state)
updateGroupNotification()
manager.notify(TAG, startId, notification)
}
fun dismiss() {
manager.cancel(TAG, startId)
states.remove(startId)
updateGroupNotification()
}
}
private fun updateGroupNotification() {
val notification = buildGroupNotification()
manager.notify(ID_GROUP, notification)
}
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntentCompat.getActivity(
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)
companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
private const val REQUEST_QUEUE = 6
private const val REQUEST_LIST_LOCAL = 7
const val ID_GROUP = 9999
private const val ID_GROUP_DETACHED = 9998
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = NotificationManagerCompat.from(context)
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW,
)
channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel)
}
}
}
}
}

View File

@@ -1,262 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.view.View
import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.collections.set
@AndroidEntryPoint
class DownloadService : BaseService() {
private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
@Inject
lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0)
private val controlReceiver = ControlReceiver()
override fun onCreate() {
super.onCreate()
downloadNotification = DownloadNotification(this)
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
ContextCompat.registerReceiver(this, controlReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size
START_REDELIVER_INTENT
} else {
stopSelfIfIdle()
START_NOT_STICKY
}
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return DownloadBinder(this)
}
override fun onDestroy() {
unregisterReceiver(controlReceiver)
if (wakeLock.isHeld) {
wakeLock.release()
}
super.onDestroy()
}
private fun downloadManga(
startId: Int,
manga: Manga,
chaptersIds: LongArray?,
): PausingProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
return job
}
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val notificationItem = downloadNotification.newItem(startId)
try {
val timeLeftEstimator = TimeLeftEstimator()
notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
timeLeftEstimator.tick(value = state.progress, total = state.max)
} else {
timeLeftEstimator.emptyTick()
}
}
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive()
.collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationItem.notify(state, timeLeft)
}
job.join()
} finally {
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
)
}
if (job.isCancelled) {
notificationItem.dismiss()
if (jobs.remove(startId) != null) {
jobCount.value = jobs.size
}
} else {
notificationItem.notify(job.progressValue, -1L)
}
}
}.invokeOnCompletion {
stopSelfIfIdle()
}
}
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
emit(state)
!state.isTerminal
}
@MainThread
private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) {
return
}
downloadNotification.detach()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.cancel()
}
ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.resume()
}
}
}
}
class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
private var downloadsStateFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
init {
service.lifecycle.addObserver(this)
service.jobCount.onEach {
downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope)
}
override fun onDestroy(owner: LifecycleOwner) {
owner.lifecycle.removeObserver(this)
downloadsStateFlow.value = emptyList()
super.onDestroy(owner)
}
val downloads
get() = downloadsStateFlow.asStateFlow()
}
companion object {
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id"
fun start(view: View, manga: Manga, chaptersIds: Collection<Long>? = null) {
if (chaptersIds?.isEmpty() == true) {
return
}
val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(view.context, intent)
showStartedSnackbar(view)
}
fun start(view: View, manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
for (item in manga) {
val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(view.context, intent)
}
showStartedSnackbar(view)
}
fun confirmAndStart(view: View, items: Set<Manga>) {
MaterialAlertDialogBuilder(view.context)
.setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
start(view, items)
}.show()
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId)
private fun showStartedSnackbar(view: View) {
Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG)
.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
}.show()
}
}
}

View File

@@ -1,42 +0,0 @@
package org.koitharu.kotatsu.explore.domain
import javax.inject.Inject
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
class ExploreRepository @Inject constructor(
private val settings: AppSettings,
private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend fun findRandomManga(tagsLimit: Int): Manga {
val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex()
val allTags = historyRepository.getPopularTags(tagsLimit).filterNot {
blacklistTagRegex?.containsMatchIn(it.title) ?: false
}
val tag = allTags.randomOrNull()
val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) {
"No sources found"
}
val repo = mangaRepositoryFactory.create(source)
val list = repo.getList(
offset = 0,
sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null,
tags = setOfNotNull(tag),
).shuffled()
for (item in list) {
if (settings.isSuggestionsExcludeNsfw && item.isNsfw) {
continue
}
if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) {
continue
}
return item
}
return list.random()
}
}

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.list.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
abstract class MangaListViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
abstract val content: LiveData<List<ListModel>>
protected val listModeFlow = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, settings.listMode)
val listMode = listModeFlow.asFlowLiveData(viewModelScope.coroutineContext)
val onActionDone = SingleLiveEvent<ReversibleAction>()
val gridScale = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_GRID_SIZE,
valueProducer = { gridSize / 100f },
)
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
abstract fun onRefresh()
abstract fun onRetry()
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingFooter : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingFooter
}

View File

@@ -1,50 +0,0 @@
package org.koitharu.kotatsu.local.data.output
import okio.Closeable
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File
sealed class LocalMangaOutput(
val rootFile: File,
) : Closeable {
abstract suspend fun mergeWithExisting()
abstract suspend fun addCover(file: File, ext: String)
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
abstract suspend fun finish()
abstract suspend fun cleanup()
companion object {
const val ENTRY_NAME_INDEX = "index.json"
const val SUFFIX_TMP = ".tmp"
fun getOrCreate(root: File, manga: Manga): LocalMangaOutput {
return checkNotNull(getImpl(root, manga, onlyIfExists = false))
}
fun get(root: File, manga: Manga): LocalMangaOutput? {
return getImpl(root, manga, onlyIfExists = true)
}
private fun getImpl(root: File, manga: Manga, onlyIfExists: Boolean): LocalMangaOutput? {
val fileName = manga.title.toFileNameSafe()
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
return when {
dir.isDirectory -> LocalMangaDirOutput(dir, manga)
zip.isFile -> LocalMangaZipOutput(zip, manga)
!onlyIfExists -> LocalMangaDirOutput(dir, manga)
else -> null
}
}
}
}

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import org.koitharu.kotatsu.parsers.model.MangaPage
fun interface OnPageSelectListener {
fun onPageSelected(page: MangaPage)
}

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage
data class PageThumbnail(
val number: Int,
val isCurrent: Boolean,
val repository: MangaRepository,
val page: MangaPage
)

View File

@@ -1,146 +0,0 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class PagesThumbnailsSheet :
BaseBottomSheet<SheetPagesBinding>(),
OnListItemClickListener<MangaPage>,
BottomSheetHeaderBar.OnExpansionChangeListener {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var pageLoader: PageLoader
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private lateinit var thumbnails: List<PageThumbnail>
private var spanResolver: MangaListSpanResolver? = null
private var currentPageIndex = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pages = arguments?.getParcelableCompat<ParcelableMangaPages>(ARG_PAGES)?.pages
if (pages.isNullOrEmpty()) {
dismissAllowingStateLoss()
return
}
currentPageIndex = requireArguments().getInt(ARG_CURRENT, currentPageIndex)
val repository = mangaRepositoryFactory.create(pages.first().source)
thumbnails = pages.mapIndexed { i, x ->
PageThumbnail(
number = i + 1,
isCurrent = i == currentPageIndex,
repository = repository,
page = x,
)
}
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
return SheetPagesBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
spanResolver = MangaListSpanResolver(view.resources)
with(binding.headerBar) {
title = arguments?.getString(ARG_TITLE)
subtitle = null
addOnExpansionChangeListener(this@PagesThumbnailsSheet)
}
with(binding.recyclerView) {
addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
)
adapter = PageThumbnailAdapter(
dataSet = thumbnails,
coil = coil,
scope = viewLifecycleScope,
loader = pageLoader,
clickListener = this@PagesThumbnailsSheet,
)
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
if (currentPageIndex > 0) {
val offset = resources.getDimensionPixelOffset(R.dimen.preferred_grid_width)
(layoutManager as GridLayoutManager).scrollToPositionWithOffset(currentPageIndex, offset)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
spanResolver = null
}
override fun onItemClick(item: MangaPage, view: View) {
(
(parentFragment as? OnPageSelectListener)
?: (activity as? OnPageSelectListener)
)?.run {
onPageSelected(item)
dismiss()
}
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) {
headerBar.subtitle = resources.getQuantityString(
R.plurals.pages,
thumbnails.size,
thumbnails.size,
)
} else {
headerBar.subtitle = null
}
}
companion object {
private const val ARG_PAGES = "pages"
private const val ARG_TITLE = "title"
private const val ARG_CURRENT = "current"
private const val TAG = "PagesThumbnailsSheet"
fun show(fm: FragmentManager, pages: List<MangaPage>, title: String, currentPage: Int) =
PagesThumbnailsSheet().withArgs(3) {
putParcelable(ARG_PAGES, ParcelableMangaPages(pages))
putString(ARG_TITLE, title)
putInt(ARG_CURRENT, currentPage)
}.show(fm, TAG)
}
}

View File

@@ -1,91 +0,0 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import android.graphics.drawable.Drawable
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.setTextColorAttr
import com.google.android.material.R as materialR
fun pageThumbnailAD(
coil: ImageLoader,
scope: CoroutineScope,
loader: PageLoader,
clickListener: OnListItemClickListener<MangaPage>,
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) },
) {
var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size(
width = gridWidth,
height = (gridWidth / 13f * 18f).toInt(),
)
suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) {
item.page.preview?.let { url ->
coil.execute(
ImageRequest.Builder(context)
.data(url)
.tag(item.page.source)
.size(thumbSize)
.scale(Scale.FILL)
.allowRgb565(true)
.build(),
).drawable
}?.let { drawable ->
return@withContext drawable
}
val file = loader.loadPage(item.page, force = false)
coil.execute(
ImageRequest.Builder(context)
.data(file)
.size(thumbSize)
.decodeRegion(0)
.allowRgb565(isLowRamDevice(context))
.build(),
).drawable
}
binding.root.setOnClickListener {
clickListener.onItemClick(item.page, itemView)
}
bind {
job?.cancel()
binding.imageViewThumb.setImageDrawable(null)
with(binding.textViewNumber) {
setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty)
setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary)
text = (item.number).toString()
}
job = scope.launch {
val drawable = runCatchingCancellable {
loadPageThumbnail(item)
}.getOrNull()
binding.imageViewThumb.setImageDrawable(drawable)
}
}
onViewRecycled {
job?.cancel()
job = null
binding.imageViewThumb.setImageDrawable(null)
}
}

View File

@@ -1,23 +0,0 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
class PageThumbnailAdapter(
dataSet: List<PageThumbnail>,
coil: ImageLoader,
scope: CoroutineScope,
loader: PageLoader,
clickListener: OnListItemClickListener<MangaPage>
) : ListDelegationAdapter<List<PageThumbnail>>() {
init {
delegatesManager.addDelegate(pageThumbnailAD(coil, scope, loader, clickListener))
setItems(dataSet)
}
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.shelf.domain
enum class ShelfSection {
HISTORY, LOCAL, UPDATED, FAVORITES;
}

View File

@@ -1,191 +0,0 @@
package org.koitharu.kotatsu.suggestions.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.FloatRange
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.work.*
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.trySetForeground
import java.util.concurrent.TimeUnit
import kotlin.math.pow
@HiltWorker
class SuggestionsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val suggestionRepository: SuggestionRepository,
private val historyRepository: HistoryRepository,
private val appSettings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val count = doWorkImpl()
val outputData = workDataOf(DATA_COUNT to count)
return Result.success(outputData)
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val title = applicationContext.getString(R.string.suggestions_updating)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
WORKER_CHANNEL_ID,
title,
NotificationManager.IMPORTANCE_LOW,
)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
manager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true)
.build()
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
}
private suspend fun doWorkImpl(): Int {
if (!appSettings.isSuggestionsEnabled) {
suggestionRepository.clear()
return 0
}
val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex()
val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot {
blacklistTagRegex?.containsMatchIn(it.title) ?: false
}
if (allTags.isEmpty()) {
return 0
}
if (TAG in tags) { // not expedited
trySetForeground()
}
val tagsBySources = allTags.groupBy { x -> x.source }
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val rawResults = coroutineScope {
tagsBySources.flatMap { (source, tags) ->
val repo = mangaRepositoryFactory.tryCreate(source) ?: return@flatMap emptyList()
tags.map { tag ->
async(dispatcher) {
repo.getListSafe(tag)
}
}
}.awaitAll().flatten().asArrayList()
}
if (appSettings.isSuggestionsExcludeNsfw) {
rawResults.removeAll { it.isNsfw }
}
if (blacklistTagRegex != null) {
rawResults.removeAll {
it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }
}
}
if (rawResults.isEmpty()) {
return 0
}
val suggestions = rawResults.distinctBy { manga ->
manga.id
}.map { manga ->
MangaSuggestion(
manga = manga,
relevance = computeRelevance(manga.tags, allTags),
)
}.sortedBy { it.relevance }.take(LIMIT)
suggestionRepository.replace(suggestions)
return suggestions.size
}
@FloatRange(from = 0.0, to = 1.0)
private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<MangaTag>): Float {
val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0
val weight = mangaTags.sumOf { tag ->
val index = allTags.indexOf(tag)
if (index < 0) 0 else allTags.size - index
}
return (weight / maxWeight).pow(2.0).toFloat()
}
private suspend fun MangaRepository.getListSafe(tag: MangaTag) = runCatchingCancellable {
getList(offset = 0, sortOrder = SortOrder.UPDATED, tags = setOf(tag))
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrDefault(emptyList())
private fun MangaRepository.Factory.tryCreate(source: MangaSource) = runCatching {
create(source)
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
companion object {
private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot"
private const val LIMIT = 140
private const val TAGS_LIMIT = 20
private const val MAX_PARALLELISM = 4
private const val DATA_COUNT = "count"
private const val WORKER_CHANNEL_ID = "suggestion_worker"
private const val WORKER_NOTIFICATION_ID = 36
fun setup(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<SuggestionsWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun startNow(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<SuggestionsWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context)
.enqueue(request)
}
}
}

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.sync.ui
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent
import javax.inject.Inject
@HiltViewModel
class SyncAuthViewModel @Inject constructor(
private val api: SyncAuthApi,
) : BaseViewModel() {
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
fun obtainToken(email: String, password: String) {
launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(email, password)
val result = SyncAuthResult(email, password, token)
onTokenObtained.emitCall(result)
}
}
}

View File

@@ -1,86 +0,0 @@
package org.koitharu.kotatsu.utils
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)

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.utils
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
return chain.proceed(newRequest.build())
}
}

View File

@@ -1,22 +0,0 @@
package org.koitharu.kotatsu.utils
import android.graphics.drawable.Drawable
import androidx.preference.Preference
import coil.target.Target
class PreferenceIconTarget(
private val preference: Preference,
) : Target {
override fun onError(error: Drawable?) {
preference.icon = error
}
override fun onStart(placeholder: Drawable?) {
preference.icon = placeholder
}
override fun onSuccess(result: Drawable) {
preference.icon = result
}
}

View File

@@ -1,50 +0,0 @@
package org.koitharu.kotatsu.utils
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)
}
}
}

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import org.koitharu.kotatsu.utils.progress.ProgressResponseBody
import java.io.InputStream
import java.io.OutputStream
suspend fun InputStream.copyToSuspending(
out: OutputStream,
bufferSize: Int = DEFAULT_BUFFER_SIZE,
progressState: MutableStateFlow<Float>? = null,
): Long = withContext(Dispatchers.IO) {
val job = currentCoroutineContext()[Job]
val total = available()
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
job?.ensureActive()
bytes = read(buffer)
job?.ensureActive()
if (progressState != null && total > 0) {
progressState.value = bytesCopied / total.toFloat()
}
}
bytesCopied
}
fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody {
return ProgressResponseBody(this, progressState)
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import android.view.View
import androidx.core.graphics.Insets
fun Insets.end(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) left else right
}
fun Insets.start(view: View): Int {
return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) right else left
}

View File

@@ -1,19 +0,0 @@
package org.koitharu.kotatsu.utils.ext
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
internal val RecyclerView.LayoutManager?.firstVisibleItemPosition
get() = when (this) {
is LinearLayoutManager -> findFirstVisibleItemPosition()
is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null)[0]
else -> 0
}
internal val RecyclerView.LayoutManager?.isLayoutReversed
get() = when (this) {
is LinearLayoutManager -> reverseLayout
is StaggeredGridLayoutManager -> reverseLayout
else -> false
}

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.utils.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.utils.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
}
}

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.utils.ext
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
return if (this.isNullOrEmpty()) defaultValue() else this
}
fun String.longHashCode(): Long {
var h = 1125899906842597L
val len: Int = this.length
for (i in 0 until len) {
h = 31 * h + this[i].code
}
return h
}

View File

@@ -20,11 +20,12 @@ import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
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.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject
@HiltAndroidApp
@@ -56,6 +57,7 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
}
WorkServiceStopHelper(applicationContext).setup()
}
override fun attachBaseContext(base: Context?) {

View File

@@ -5,7 +5,6 @@ import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.toBookmark
import org.koitharu.kotatsu.bookmarks.data.toBookmarks
@@ -14,9 +13,10 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@Reusable

View File

@@ -3,16 +3,14 @@ package org.koitharu.kotatsu.bookmarks.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
@@ -24,10 +22,10 @@ class BookmarksActivity :
SnackbarOwner {
override val appBar: AppBarLayout
get() = binding.appbar
get() = viewBinding.appbar
override val snackbarHost: CoordinatorLayout
get() = binding.root
get() = viewBinding.root
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -43,7 +41,7 @@ class BookmarksActivity :
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)

View File

@@ -16,19 +16,23 @@ import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.bookmarks.data.ids
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync
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.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -37,8 +41,6 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint
@@ -56,12 +58,12 @@ class BookmarksFragment :
private var adapter: BookmarksGroupAdapter? = null
private var selectionController: SectionedSelectionController<Manga>? = null
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = SectionedSelectionController(
activity = requireActivity(),
owner = this,
@@ -77,12 +79,12 @@ class BookmarksFragment :
)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
val spacingDecoration = SpacingItemDecoration(view.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
val spacingDecoration = SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
@@ -114,7 +116,7 @@ class BookmarksFragment :
override fun onFastScrollStop(fastScroller: FastScroller) = Unit
override fun onSelectionChanged(controller: SectionedSelectionController<Manga>, count: Int) {
binding.recyclerView.invalidateNestedItemDecorations()
requireViewBinding().recyclerView.invalidateNestedItemDecorations()
}
override fun onCreateActionMode(
@@ -149,10 +151,10 @@ class BookmarksFragment :
): AbstractSelectionItemDecoration = BookmarksSelectionDecoration(requireContext())
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
)
binding.recyclerView.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
requireViewBinding().recyclerView.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom
}
}

View File

@@ -4,8 +4,8 @@ import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.utils.ext.getItem
class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
@@ -14,5 +14,4 @@ class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(
val item = holder.getItem(Bookmark::class.java) ?: return RecyclerView.NO_ID
return item.pageId
}
}

View File

@@ -1,23 +1,26 @@
package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import javax.inject.Inject
@HiltViewModel
@@ -25,9 +28,9 @@ class BookmarksViewModel @Inject constructor(
private val repository: BookmarksRepository,
) : 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 ->
if (list.isEmpty()) {
listOf(
@@ -43,12 +46,12 @@ class BookmarksViewModel @Inject constructor(
}
}
.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>>) {
launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids)
onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle))
onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
}
}
}

View File

@@ -4,16 +4,16 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarkListAD(
coil: ImageLoader,

View File

@@ -4,8 +4,8 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
class BookmarksAdapter(
coil: ImageLoader,
@@ -13,7 +13,7 @@ class BookmarksAdapter(
clickListener: OnListItemClickListener<Bookmark>,
) : AsyncListDifferDelegationAdapter<Bookmark>(
DiffCallback(),
bookmarkListAD(coil, lifecycleOwner, clickListener)
bookmarkListAD(coil, lifecycleOwner, clickListener),
) {
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {

View File

@@ -6,20 +6,20 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.clearItemDecorations
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarksGroupAD(
coil: ImageLoader,

View File

@@ -5,16 +5,17 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.jvm.internal.Intrinsics
@@ -54,6 +55,10 @@ class BookmarksGroupAdapter(
oldItem.manga.id == newItem.manga.id
}
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass
}
}

View File

@@ -12,8 +12,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import com.google.android.material.R as materialR
@@ -24,18 +25,20 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater))
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
with(binding.webView.settings) {
with(viewBinding.webView.settings) {
javaScriptEnabled = true
userAgentString = CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(binding.webView)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
if (savedInstanceState != null) {
return
@@ -48,18 +51,18 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
url,
)
binding.webView.loadUrl(url)
viewBinding.webView.loadUrl(url)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
binding.webView.saveState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
binding.webView.restoreState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -70,14 +73,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
binding.webView.stopLoading()
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(binding.webView.url)
intent.data = Uri.parse(viewBinding.webView.url)
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
@@ -89,22 +92,22 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
override fun onPause() {
binding.webView.onPause()
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
binding.webView.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
binding.webView.destroy()
viewBinding.webView.destroy()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
@@ -117,10 +120,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.appbar.updatePadding(
viewBinding.appbar.updatePadding(
top = insets.top,
)
binding.root.updatePadding(
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,

View File

@@ -1,10 +1,8 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebSettings
@@ -14,13 +12,13 @@ import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
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 org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
@@ -39,14 +37,13 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onInflateView(
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false)
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
@@ -64,8 +61,8 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
}
override fun onDestroyView() {
binding.webView.stopLoading()
binding.webView.destroy()
requireViewBinding().webView.stopLoading()
requireViewBinding().webView.destroy()
onBackPressedCallback = null
super.onDestroyView()
}
@@ -76,18 +73,18 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(binding.webView).also {
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() {
super.onResume()
binding.webView.onResume()
requireViewBinding().webView.onResume()
}
override fun onPause() {
binding.webView.onPause()
requireViewBinding().webView.onPause()
super.onPause()
}
@@ -97,7 +94,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
}
override fun onPageLoaded() {
bindingOrNull()?.progressBar?.isInvisible = true
viewBinding?.progressBar?.isInvisible = true
}
override fun onCheckPassed() {

View File

@@ -4,7 +4,6 @@ import android.app.Application
import android.content.Context
import android.provider.SearchRecentSuggestions
import android.text.Html
import android.util.AndroidRuntimeException
import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker
import coil.ComponentRegistry
@@ -23,50 +22,42 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
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.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir
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.LocalStorageManager
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.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.utils.IncognitoModeIndicator
import org.koitharu.kotatsu.utils.ext.activityManager
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.image.CoilImageGetter
import org.koitharu.kotatsu.widget.WidgetUpdater
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface AppModule {
@Binds
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
@Binds
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
@@ -75,47 +66,6 @@ interface AppModule {
companion object {
@Provides
@Singleton
fun provideCookieJar(
@ApplicationContext context: Context
): MutableCookieJar = try {
AndroidCookieJar()
} catch (e: AndroidRuntimeException) {
// WebView is not available
PreferencesCookieJar(context)
}
@Provides
@Singleton
fun provideOkHttpClient(
localStorageManager: LocalStorageManager,
commonHeadersInterceptor: CommonHeadersInterceptor,
mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cookieJar: CookieJar,
settings: AppSettings,
): OkHttpClient {
val cache = localStorageManager.createHttpCache()
return OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) {
bypassSSLErrors()
}
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor())
addInterceptor(mirrorSwitchInterceptor)
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build()
}
@Provides
@Singleton
fun provideNetworkState(
@@ -134,14 +84,10 @@ interface AppModule {
@Singleton
fun provideCoil(
@ApplicationContext context: Context,
okHttpClient: OkHttpClient,
@MangaHttpClient okHttpClient: OkHttpClient,
mangaRepositoryFactory: MangaRepository.Factory,
pagesCache: PagesCache,
): ImageLoader {
val httpClientFactory = {
okHttpClient.newBuilder()
.cache(null)
.build()
}
val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
@@ -149,19 +95,20 @@ interface AppModule {
.build()
}
return ImageLoader.Builder(context)
.okHttpClient(httpClientFactory)
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.IO)
.decoderDispatcher(Dispatchers.Default)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(isLowRamDevice(context))
.allowRgb565(context.isLowRamDevice())
.components(
ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(CbzFetcher.Factory())
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.add(MangaPageFetcher.Factory(context, okHttpClient, pagesCache, mangaRepositoryFactory))
.build(),
).build()
}

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
private const val PAGE_SIZE = 10

View File

@@ -5,10 +5,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.utils.ext.format
import java.io.File
import java.util.*
import java.util.Date
import java.util.Locale
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null
if (value.isExpired) {
cache.remove(key)
}
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
fun clear() {
cache.evictAll()
}
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit
class ExpiringValue<T>(
private val value: T,
lifetime: Long,
timeUnit: TimeUnit,
) {
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
val isExpired: Boolean
get() = SystemClock.elapsedRealtime() >= expiresAt
fun get(): T? = if (isExpired) null else value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExpiringValue<*>
if (value != other.value) return false
return expiresAt == other.expiresAt
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + expiresAt.hashCode()
return result
}
}

View File

@@ -6,6 +6,7 @@ import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
@@ -13,8 +14,8 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
application.registerComponentCallbacks(this)
}
private val detailsCache = DeferredLruCache<Manga>(4)
private val pagesCache = DeferredLruCache<List<MangaPage>>(4)
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true
@@ -23,7 +24,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache.put(ContentCache.Key(source, url), details)
detailsCache[ContentCache.Key(source, url)] = details
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
@@ -31,7 +32,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache.put(ContentCache.Key(source, url), pages)
pagesCache[ContentCache.Key(source, url)] = pages
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@@ -43,17 +44,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
trimCache(pagesCache, level)
}
private fun trimCache(cache: DeferredLruCache<*>, level: Int) {
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll()
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize() / 2)
else -> cache.trimToSize(cache.maxSize / 2)
}
}
}

View File

@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration6To7
import org.koitharu.kotatsu.core.db.migrations.Migration7To8
import org.koitharu.kotatsu.core.db.migrations.Migration8To9
import org.koitharu.kotatsu.core.db.migrations.Migration9To10
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -46,7 +47,6 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
const val DATABASE_VERSION = 15

View File

@@ -1,10 +1,13 @@
package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.longHashCode
// Entity to model
@@ -66,7 +69,6 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)
@Suppress("FunctionName")
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()

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