Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12e9fb5aab | |||
| 5fc08d9ecb | |||
|
|
e7eb61e3e5 | ||
|
|
dae3982e67 | ||
|
|
45b2d2bebe | ||
| 77fa34835f | |||
| cee68069d6 | |||
|
|
48afc8624b | ||
| b9b41ed491 | |||
| 427272aac1 | |||
| 78f417ebe1 | |||
| 3fd6bec433 | |||
| 262e26a0cc | |||
| 1b64c2a330 | |||
| 5ea0ecbd12 | |||
|
|
f9a1d1617e | ||
|
|
10d8365fc1 | ||
| 85065c57a1 | |||
|
|
75e130b97c | ||
|
|
df99eec429 | ||
|
|
3a40820991 | ||
|
|
27bd2e74ca | ||
| a308688a2e | |||
| 259c845912 | |||
| a89ff4d15d | |||
| 3ed9ed8cab | |||
| 40b9577e69 | |||
| b87ae19712 | |||
| 1dc7e61dbd |
@@ -15,8 +15,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 33
|
||||
versionCode 537
|
||||
versionName '5.0-rc2'
|
||||
versionCode 540
|
||||
versionName '5.0.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -78,17 +78,17 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:c321faeaa9') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:96b9ac36f3') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.20'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.7.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.6'
|
||||
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'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
|
||||
@@ -100,7 +100,7 @@ dependencies {
|
||||
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.8.0'
|
||||
implementation 'com.google.android.material:material:1.9.0'
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 791 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 398 KiB |
@@ -1,10 +0,0 @@
|
||||
Slice of Life, Mystery
|
||||
Slice of Life, Mystery
|
||||
Psychological, Romance, Comedy, Slice of Life, Supernatural
|
||||
Sci-Fi, Comedy
|
||||
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||
Adventure, Slice of Life, Mystery
|
||||
Adventure, Slice of Life, Mystery
|
||||
@@ -1,10 +0,0 @@
|
||||
Forget-me-not Vol. 1
|
||||
Forget-me-not Vol. 2
|
||||
La Pomme Prisoinniere
|
||||
Momo Kanchou no Himitsu Kichi
|
||||
Omoide Emanon
|
||||
Sasurai Emanon Vol. 1
|
||||
Sasurai Emanon Vol. 2
|
||||
Sasurai Emanon Vol. 3
|
||||
Wandering Island Vol. 1
|
||||
Wandering Island Vol. 2
|
||||
@@ -4,6 +4,7 @@ import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -30,12 +31,11 @@ import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val MIN_WEBTOON_RATIO = 2
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class MangaDataRepository @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val db: MangaDatabase,
|
||||
|
||||
@@ -11,9 +11,9 @@ import org.koitharu.kotatsu.utils.ext.getParcelableCompat
|
||||
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
|
||||
|
||||
class MangaIntent private constructor(
|
||||
val manga: Manga?,
|
||||
val mangaId: Long,
|
||||
val uri: Uri?,
|
||||
@JvmField val manga: Manga?,
|
||||
@JvmField val mangaId: Long,
|
||||
@JvmField val uri: Uri?,
|
||||
) {
|
||||
|
||||
constructor(intent: Intent?) : this(
|
||||
|
||||
@@ -26,34 +26,33 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.inject
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
private var isAmoledTheme = false
|
||||
|
||||
protected lateinit var binding: B
|
||||
private set
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@JvmField
|
||||
protected val exceptionResolver = ExceptionResolver(this)
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
@JvmField
|
||||
val actionModeDelegate = ActionModeDelegate()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
|
||||
val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
|
||||
isAmoledTheme = settings.isAmoledTheme
|
||||
setTheme(settings.colorScheme.styleResId)
|
||||
if (settings.isAmoledTheme) {
|
||||
if (isAmoledTheme) {
|
||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -62,6 +61,11 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
putDataToExtras(intent)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
onBackPressedDispatcher.addCallback(actionModeDelegate)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
putDataToExtras(intent)
|
||||
super.onNewIntent(intent)
|
||||
@@ -108,7 +112,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
protected fun isDarkAmoledTheme(): Boolean {
|
||||
val uiMode = resources.configuration.uiMode
|
||||
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
return isNight && settings.isAmoledTheme
|
||||
return isNight && isAmoledTheme
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
Fragment(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
@@ -19,10 +20,10 @@ abstract class BaseFragment<B : ViewBinding> :
|
||||
protected val binding: B
|
||||
get() = checkNotNull(viewBinding)
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@JvmField
|
||||
protected val exceptionResolver = ExceptionResolver(this)
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
protected val actionModeDelegate: ActionModeDelegate
|
||||
|
||||
@@ -9,12 +9,13 @@ import androidx.core.view.updatePadding
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@AndroidEntryPoint
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
@@ -24,7 +25,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
@JvmField
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
override val recyclerView: RecyclerView
|
||||
@@ -55,7 +56,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
protected fun setTitle(title: CharSequence) {
|
||||
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
|
||||
?: activity?.setTitle(title)
|
||||
|
||||
@@ -3,16 +3,24 @@ package org.koitharu.kotatsu.base.ui
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
@JvmField
|
||||
protected val loadingCounter = CountedBooleanLiveData()
|
||||
|
||||
@JvmField
|
||||
protected val errorEvent = SingleLiveEvent<Throwable>()
|
||||
|
||||
val onError: LiveData<Throwable>
|
||||
@@ -46,4 +54,4 @@ abstract class BaseViewModel : ViewModel() {
|
||||
errorEvent.postCall(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -20,7 +19,7 @@ abstract class CoroutineIntentService : BaseService() {
|
||||
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
launchCoroutine(intent, startId)
|
||||
return Service.START_REDELIVER_INTENT
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
|
||||
RecyclerView.OnScrollListener() {
|
||||
|
||||
constructor(offset: Int = 0) : this(offset, offset)
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
|
||||
@@ -28,4 +26,4 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
|
||||
abstract fun onScrolledToStart(recyclerView: RecyclerView)
|
||||
|
||||
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
class ActionModeDelegate {
|
||||
class ActionModeDelegate : OnBackPressedCallback(false) {
|
||||
|
||||
private var activeActionMode: ActionMode? = null
|
||||
private var listeners: MutableList<ActionModeListener>? = null
|
||||
@@ -12,13 +13,19 @@ class ActionModeDelegate {
|
||||
val isActionModeStarted: Boolean
|
||||
get() = activeActionMode != null
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
activeActionMode?.finish()
|
||||
}
|
||||
|
||||
fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
activeActionMode = mode
|
||||
isEnabled = true
|
||||
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||
}
|
||||
|
||||
fun onSupportActionModeFinished(mode: ActionMode) {
|
||||
activeActionMode = null
|
||||
isEnabled = false
|
||||
listeners?.forEach { it.onActionModeFinished(mode) }
|
||||
}
|
||||
|
||||
@@ -47,4 +54,4 @@ class ActionModeDelegate {
|
||||
removeListener(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.ActivityCompat
|
||||
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
|
||||
import java.util.WeakHashMap
|
||||
import javax.inject.Inject
|
||||
@@ -22,6 +23,6 @@ class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleC
|
||||
|
||||
fun recreateAll() {
|
||||
val snapshot = activities.keys.toList()
|
||||
snapshot.forEach { it.recreate() }
|
||||
snapshot.forEach { ActivityCompat.recreate(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.base.ui.util
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@EntryPoint
|
||||
@@ -11,8 +10,3 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
interface BaseActivityEntryPoint {
|
||||
val settings: AppSettings
|
||||
}
|
||||
|
||||
// Hilt cannot inject into parametrized classes
|
||||
fun BaseActivityEntryPoint.inject(activity: BaseActivity<*>) {
|
||||
activity.settings = settings
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ class WindowInsetsDelegate(
|
||||
private val listener: WindowInsetsListener,
|
||||
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
|
||||
|
||||
@JvmField
|
||||
var handleImeInsets: Boolean = false
|
||||
|
||||
@JvmField
|
||||
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
|
||||
|
||||
private var lastInsets: Insets? = null
|
||||
@@ -63,4 +65,4 @@ class WindowInsetsDelegate(
|
||||
|
||||
fun onWindowInsetsChanged(insets: Insets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import android.database.SQLException
|
||||
import androidx.room.withTransaction
|
||||
import javax.inject.Inject
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.base.domain.ReversibleHandle
|
||||
@@ -17,9 +17,9 @@ import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import javax.inject.Singleton
|
||||
import javax.inject.Inject
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class BookmarksRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
@@ -48,7 +48,7 @@ class BookmarksViewModel @Inject constructor(
|
||||
fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val handle = repository.removeBookmarks(ids)
|
||||
onActionDone.postCall(ReversibleAction(R.string.bookmarks_removed, handle))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
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
|
||||
@@ -40,6 +43,8 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
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.main.ui.protect.AppProtectHelper
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
@@ -169,7 +174,6 @@ interface AppModule {
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ElementsIntoSet
|
||||
fun provideDatabaseObservers(
|
||||
widgetUpdater: WidgetUpdater,
|
||||
@@ -184,7 +188,6 @@ interface AppModule {
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ElementsIntoSet
|
||||
fun provideActivityLifecycleCallbacks(
|
||||
appProtectHelper: AppProtectHelper,
|
||||
@@ -207,5 +210,16 @@ interface AppModule {
|
||||
MemoryContentCache(application)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@LocalStorageChanges
|
||||
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
|
||||
|
||||
@Provides
|
||||
@LocalStorageChanges
|
||||
fun provideLocalStorageChangesFlow(
|
||||
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
|
||||
): SharedFlow<LocalManga?> = flow.asSharedFlow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import okio.IOException
|
||||
|
||||
class CloudFlareProtectedException(
|
||||
val url: String,
|
||||
val headers: Headers,
|
||||
@Transient val headers: Headers,
|
||||
) : IOException("Protected by CloudFlare")
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class WrongPasswordException : SecurityException()
|
||||
class WrongPasswordException : IllegalArgumentException()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
@@ -8,6 +9,18 @@ import org.koitharu.kotatsu.utils.ext.iterator
|
||||
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
}
|
||||
val acc = HashMap<String?, Int>()
|
||||
for (item in this) {
|
||||
val branch = item.chapter.branch
|
||||
acc[branch] = (acc[branch] ?: 0) + 1
|
||||
}
|
||||
return acc.values.max()
|
||||
}
|
||||
|
||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val ch = chapters
|
||||
if (ch.isNullOrEmpty()) {
|
||||
@@ -31,4 +44,4 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
}
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.utils.ext.toList
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -28,10 +29,14 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
@ApplicationContext private val androidContext: Context,
|
||||
) : MangaLoaderContext() {
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
|
||||
val webView = WebView(androidContext)
|
||||
webView.settings.javaScriptEnabled = true
|
||||
val webView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.settings.javaScriptEnabled = true
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
|
||||
@@ -16,7 +16,12 @@ class MangaTagHighlighter @Inject constructor(
|
||||
private val dict by lazy {
|
||||
context.resources.openRawResource(R.raw.tags_redlist).use {
|
||||
val set = HashSet<String>()
|
||||
it.bufferedReader().forEachLine { x -> set.add(x) }
|
||||
it.bufferedReader().forEachLine { x ->
|
||||
val line = x.trim()
|
||||
if (line.isNotEmpty()) {
|
||||
set.add(line)
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.getParcelableExtraCompat
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -95,7 +96,12 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
val intent = Intent(context, MangaPrefetchService::class.java)
|
||||
intent.action = ACTION_PREFETCH_PAGES
|
||||
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
|
||||
context.startService(intent)
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (e: IllegalStateException) {
|
||||
// probably app is in background
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
fun prefetchLast(context: Context) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import android.transition.Slide
|
||||
import android.transition.TransitionManager
|
||||
@@ -66,13 +64,6 @@ class DetailsActivity :
|
||||
private val viewModel: DetailsViewModel by viewModels()
|
||||
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
|
||||
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return
|
||||
viewModel.onDownloadComplete(downloadedManga)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
|
||||
@@ -130,7 +121,6 @@ class DetailsActivity :
|
||||
}
|
||||
viewModel.chapters.observe(this, PrefetchObserver(this))
|
||||
|
||||
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
|
||||
addMenuProvider(
|
||||
DetailsMenuProvider(
|
||||
activity = this,
|
||||
@@ -142,11 +132,6 @@ class DetailsActivity :
|
||||
binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(downloadReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
when (v.id) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.widget.Toast
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -19,7 +18,6 @@ import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.chip.Chip
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
@@ -27,6 +25,7 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||
import org.koitharu.kotatsu.core.model.countChaptersByBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
|
||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
@@ -45,7 +44,6 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.computeSize
|
||||
import org.koitharu.kotatsu.utils.ext.crossfade
|
||||
import org.koitharu.kotatsu.utils.ext.drawableTop
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
@@ -54,8 +52,6 @@ import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.utils.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.utils.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -93,6 +89,7 @@ class DetailsFragment :
|
||||
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
|
||||
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
|
||||
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
|
||||
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
@@ -149,20 +146,9 @@ class DetailsFragment :
|
||||
}
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
infoLayout.textViewSource.isVisible = false
|
||||
val file = manga.url.toUri().toFileOrNull()
|
||||
if (file != null) {
|
||||
viewLifecycleScope.launch {
|
||||
val size = file.computeSize()
|
||||
infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size)
|
||||
infoLayout.textViewSize.isVisible = true
|
||||
}
|
||||
} else {
|
||||
infoLayout.textViewSize.isVisible = false
|
||||
}
|
||||
} else {
|
||||
infoLayout.textViewSource.text = manga.source.title
|
||||
infoLayout.textViewSource.isVisible = true
|
||||
infoLayout.textViewSize.isVisible = false
|
||||
}
|
||||
|
||||
infoLayout.textViewNsfw.isVisible = manga.isNsfw
|
||||
@@ -177,12 +163,9 @@ class DetailsFragment :
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
infoLayout.textViewChapters.isVisible = false
|
||||
} else {
|
||||
val count = chapters.countChaptersByBranch()
|
||||
infoLayout.textViewChapters.isVisible = true
|
||||
infoLayout.textViewChapters.text = resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
chapters.size,
|
||||
chapters.size,
|
||||
)
|
||||
infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +177,16 @@ class DetailsFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocalSizeChanged(size: Long) {
|
||||
val textView = binding.infoLayout.textViewSize
|
||||
if (size == 0L) {
|
||||
textView.isVisible = false
|
||||
} else {
|
||||
textView.text = FileSize.BYTES.format(textView.context, size)
|
||||
textView.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun onHistoryChanged(history: HistoryInfo) {
|
||||
binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -14,6 +15,7 @@ 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
|
||||
@@ -36,6 +38,8 @@ 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
|
||||
@@ -46,8 +50,10 @@ 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
|
||||
|
||||
@@ -62,6 +68,7 @@ class DetailsViewModel @Inject constructor(
|
||||
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
|
||||
@@ -95,19 +102,37 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
val historyInfo: LiveData<HistoryInfo> = combine(
|
||||
delegate.manga,
|
||||
delegate.selectedBranch,
|
||||
history,
|
||||
historyRepository.observeShouldSkip(delegate.manga),
|
||||
) { m, h, im ->
|
||||
HistoryInfo(m, h, im)
|
||||
) { m, b, h, im ->
|
||||
HistoryInfo(m, b, h, im)
|
||||
}.asFlowLiveData(
|
||||
context = viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
defaultValue = HistoryInfo(null, null, false),
|
||||
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 {
|
||||
@@ -173,6 +198,10 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadingJob = doLoad()
|
||||
launchJob(Dispatchers.Default) {
|
||||
localStorageChanges
|
||||
.collect { onDownloadComplete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
@@ -194,7 +223,7 @@ class DetailsViewModel @Inject constructor(
|
||||
runCatchingCancellable {
|
||||
historyRepository.deleteOrSwap(manga, original)
|
||||
}
|
||||
onMangaRemoved.postCall(manga)
|
||||
onMangaRemoved.emitCall(manga)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,26 +250,6 @@ class DetailsViewModel @Inject constructor(
|
||||
chaptersQuery.value = query?.trim().orEmpty()
|
||||
}
|
||||
|
||||
fun onDownloadComplete(downloadedManga: Manga) {
|
||||
val currentManga = delegate.manga.value ?: return
|
||||
if (currentManga.id != downloadedManga.id) {
|
||||
return
|
||||
}
|
||||
if (currentManga.source == MangaSource.LOCAL) {
|
||||
reload()
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
runCatchingCancellable {
|
||||
localMangaRepository.getDetails(downloadedManga)
|
||||
}.onSuccess {
|
||||
delegate.relatedManga.value = it
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
|
||||
val scrobbler = getScrobbler(index) ?: return
|
||||
launchJob(Dispatchers.Default) {
|
||||
@@ -286,6 +295,27 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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>()
|
||||
|
||||
@@ -36,8 +36,13 @@ class HistoryInfo(
|
||||
}
|
||||
}
|
||||
|
||||
fun HistoryInfo(manga: Manga?, history: MangaHistory?, isIncognitoMode: Boolean): HistoryInfo {
|
||||
val chapters = manga?.chapters
|
||||
fun HistoryInfo(
|
||||
manga: Manga?,
|
||||
branch: String?,
|
||||
history: MangaHistory?,
|
||||
isIncognitoMode: Boolean
|
||||
): HistoryInfo {
|
||||
val chapters = manga?.getChapters(branch)
|
||||
return HistoryInfo(
|
||||
totalChapters = chapters?.size ?: -1,
|
||||
currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -29,6 +30,8 @@ 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
|
||||
@@ -38,6 +41,7 @@ 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
|
||||
@@ -58,6 +62,7 @@ class DownloadManager @Inject constructor(
|
||||
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(
|
||||
@@ -120,9 +125,11 @@ class DownloadManager @Inject constructor(
|
||||
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 ?: data.coverUrl
|
||||
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
|
||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||
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) {
|
||||
@@ -165,13 +172,18 @@ class DownloadManager @Inject constructor(
|
||||
delay(SLOWDOWN_DELAY)
|
||||
}
|
||||
}
|
||||
output.flushChapter(chapter)
|
||||
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().manga
|
||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -28,7 +29,6 @@ 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.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DownloadNotification(private val context: Context) {
|
||||
@@ -37,18 +37,20 @@ class DownloadNotification(private val context: Context) {
|
||||
private val states = SparseArray<DownloadState>()
|
||||
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
|
||||
private val queueIntent = PendingIntent.getActivity(
|
||||
private val queueIntent = PendingIntentCompat.getActivity(
|
||||
context,
|
||||
REQUEST_QUEUE,
|
||||
DownloadsActivity.newIntent(context),
|
||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
|
||||
private val localListIntent = PendingIntent.getActivity(
|
||||
private val localListIntent = PendingIntentCompat.getActivity(
|
||||
context,
|
||||
REQUEST_LIST_LOCAL,
|
||||
MangaListActivity.newIntent(context, MangaSource.LOCAL),
|
||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -78,31 +80,37 @@ class DownloadNotification(private val context: Context) {
|
||||
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
|
||||
@@ -158,21 +166,23 @@ class DownloadNotification(private val context: Context) {
|
||||
private val cancelAction = NotificationCompat.Action(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
context.getString(android.R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
startId * 2,
|
||||
DownloadService.getCancelIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
),
|
||||
)
|
||||
private val retryAction = NotificationCompat.Action(
|
||||
R.drawable.ic_restart_black,
|
||||
context.getString(R.string.try_again),
|
||||
PendingIntent.getBroadcast(
|
||||
PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
startId * 2 + 1,
|
||||
DownloadService.getResumeIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -213,6 +223,7 @@ class DownloadNotification(private val context: Context) {
|
||||
builder.setOngoing(true)
|
||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
is DownloadState.Done -> {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.download_complete))
|
||||
@@ -226,6 +237,7 @@ class DownloadNotification(private val context: Context) {
|
||||
builder.setWhen(System.currentTimeMillis())
|
||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
is DownloadState.Error -> {
|
||||
val message = state.error.getDisplayMessage(context.resources)
|
||||
builder.setProgress(0, 0, false)
|
||||
@@ -244,6 +256,7 @@ class DownloadNotification(private val context: Context) {
|
||||
}
|
||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
is DownloadState.PostProcessing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.processing_))
|
||||
@@ -251,6 +264,7 @@ class DownloadNotification(private val context: Context) {
|
||||
builder.setOngoing(true)
|
||||
builder.priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
is DownloadState.Queued -> {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.queued))
|
||||
@@ -259,6 +273,7 @@ class DownloadNotification(private val context: Context) {
|
||||
builder.addAction(cancelAction)
|
||||
builder.priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
|
||||
is DownloadState.Preparing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.preparing_))
|
||||
@@ -267,6 +282,7 @@ class DownloadNotification(private val context: Context) {
|
||||
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())
|
||||
@@ -302,11 +318,12 @@ class DownloadNotification(private val context: Context) {
|
||||
manager.notify(ID_GROUP, notification)
|
||||
}
|
||||
|
||||
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
||||
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntentCompat.getActivity(
|
||||
context,
|
||||
manga.hashCode(),
|
||||
DetailsActivity.newIntent(context, manga),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -56,7 +57,6 @@ class DownloadService : BaseService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
downloadNotification = DownloadNotification(this)
|
||||
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
@@ -93,7 +93,6 @@ class DownloadService : BaseService() {
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -205,12 +204,6 @@ class DownloadService : BaseService() {
|
||||
|
||||
companion object {
|
||||
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
@Deprecated("Use LocalMangaRepository.watchReadableDirs instead")
|
||||
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
|
||||
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"
|
||||
|
||||
@@ -259,13 +252,6 @@ class DownloadService : BaseService() {
|
||||
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
fun getDownloadedManga(intent: Intent?): Manga? {
|
||||
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
|
||||
return intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showStartedSnackbar(view: View) {
|
||||
Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.details) {
|
||||
|
||||
@@ -54,7 +54,7 @@ class ExploreViewModel @Inject constructor(
|
||||
fun openRandom() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val manga = exploreRepository.findRandomManga(tagsLimit = 8)
|
||||
onOpenManga.postCall(manga)
|
||||
onOpenManga.emitCall(manga)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class ExploreViewModel @Inject constructor(
|
||||
val rollback = ReversibleHandle {
|
||||
settings.hiddenSources -= source.name
|
||||
}
|
||||
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.favourites.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -22,9 +23,8 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class FavouritesRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val channels: TrackerNotificationChannels,
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEdit
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -34,7 +35,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
category.postValue(
|
||||
category.emitValue(
|
||||
if (categoryId != NO_ID) {
|
||||
repository.getCategory(categoryId)
|
||||
} else {
|
||||
@@ -57,7 +58,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
|
||||
} else {
|
||||
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf)
|
||||
}
|
||||
onSaved.postCall(Unit)
|
||||
onSaved.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
|
||||
@@ -28,7 +27,6 @@ import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -43,9 +41,6 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||
|
||||
var categoryName: String? = null
|
||||
private set
|
||||
|
||||
val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
|
||||
MutableLiveData(null)
|
||||
} else {
|
||||
@@ -82,18 +77,6 @@ class FavouritesListViewModel @Inject constructor(
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
|
||||
init {
|
||||
if (categoryId != NO_ID) {
|
||||
launchJob {
|
||||
categoryName = withContext(Dispatchers.Default) {
|
||||
runCatchingCancellable {
|
||||
repository.getCategory(categoryId).title
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() = Unit
|
||||
|
||||
override fun onRetry() = Unit
|
||||
@@ -108,7 +91,7 @@ class FavouritesListViewModel @Inject constructor(
|
||||
} else {
|
||||
repository.removeFromCategory(categoryId, ids)
|
||||
}
|
||||
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.history.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
@@ -27,11 +28,10 @@ import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
const val PROGRESS_NONE = -1f
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class HistoryRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import org.koitharu.kotatsu.utils.ext.onFirst
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -45,7 +46,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
val isGroupingEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
|
||||
.onEach { isGroupingEnabled.postValue(it) }
|
||||
.onEach { isGroupingEnabled.emitValue(it) }
|
||||
|
||||
override val content = combine(
|
||||
repository.observeAllWithHistory(),
|
||||
@@ -77,7 +78,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
override fun onRetry() = Unit
|
||||
|
||||
fun clearHistory() {
|
||||
launchLoadingJob {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
repository.clear()
|
||||
}
|
||||
}
|
||||
@@ -88,7 +89,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
val handle = repository.delete(ids)
|
||||
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,9 @@ import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.reverseAsync
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
|
||||
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
|
||||
@@ -30,7 +28,7 @@ import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
|
||||
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
@@ -46,7 +44,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
@@ -127,7 +124,7 @@ abstract class MangaListFragment :
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
||||
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
|
||||
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
|
||||
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -173,17 +170,6 @@ abstract class MangaListFragment :
|
||||
listAdapter?.setItems(list, listCommitCallback)
|
||||
}
|
||||
|
||||
private fun onActionDone(action: ReversibleAction) {
|
||||
val handle = action.handle
|
||||
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
|
||||
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
|
||||
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
|
||||
if (handle != null) {
|
||||
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
private fun resolveException(e: Throwable) {
|
||||
if (ExceptionResolver.canResolve(e)) {
|
||||
viewLifecycleScope.launch {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:androidx.annotation.OptIn(ExperimentalBadgeUtils::class)
|
||||
|
||||
package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
@@ -5,6 +7,7 @@ import androidx.annotation.CheckResult
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import com.google.android.material.badge.BadgeDrawable
|
||||
import com.google.android.material.badge.BadgeUtils
|
||||
import com.google.android.material.badge.ExperimentalBadgeUtils
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
@CheckResult
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyHint
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
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.setTextAndVisible
|
||||
|
||||
fun emptyHintAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: ListStateHolderListener,
|
||||
) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>(
|
||||
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
|
||||
@@ -15,9 +22,13 @@ fun emptyHintAD(
|
||||
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
|
||||
|
||||
bind {
|
||||
binding.icon.setImageResource(item.icon)
|
||||
binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
|
||||
binding.textPrimary.setText(item.textPrimary)
|
||||
binding.textSecondary.setTextAndVisible(item.textSecondary)
|
||||
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
binding.icon.disposeImageRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package org.koitharu.kotatsu.local
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import java.io.File
|
||||
@@ -9,6 +11,8 @@ class LocalManga(
|
||||
val manga: Manga,
|
||||
) {
|
||||
|
||||
constructor(manga: Manga) : this(manga.url.toUri().toFile(), manga)
|
||||
|
||||
var createdAt: Long = -1L
|
||||
private set
|
||||
get() {
|
||||
|
||||
@@ -4,30 +4,25 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.util.observe
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.utils.ext.computeSize
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class LocalStorageManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
@@ -36,6 +31,7 @@ class LocalStorageManager @Inject constructor(
|
||||
val contentResolver: ContentResolver
|
||||
get() = context.contentResolver
|
||||
|
||||
@WorkerThread
|
||||
fun createHttpCache(): Cache {
|
||||
val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
|
||||
directory.mkdirs()
|
||||
@@ -80,14 +76,6 @@ class LocalStorageManager @Inject constructor(
|
||||
|
||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
||||
|
||||
fun observe(files: List<File>): Flow<File> {
|
||||
if (files.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
return files.asFlow()
|
||||
.flatMapMerge(files.size) { it.observe() }
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||
val set = getAvailableStorageDirs()
|
||||
|
||||
@@ -84,7 +84,7 @@ class MangaIndex(source: String?) {
|
||||
|
||||
fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
|
||||
|
||||
fun addChapter(chapter: MangaChapter) {
|
||||
fun addChapter(chapter: MangaChapter, filename: String?) {
|
||||
val chapters = json.getJSONObject("chapters")
|
||||
if (!chapters.has(chapter.id.toString())) {
|
||||
val jo = JSONObject()
|
||||
@@ -95,6 +95,7 @@ class MangaIndex(source: String?) {
|
||||
jo.put("scanlator", chapter.scanlator)
|
||||
jo.put("branch", chapter.branch)
|
||||
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
|
||||
jo.put("file", filename)
|
||||
chapters.put(chapter.id.toString(), jo)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +104,10 @@ class MangaIndex(source: String?) {
|
||||
return json.getJSONObject("chapters").remove(id.toString()) != null
|
||||
}
|
||||
|
||||
fun getChapterFileName(chapterId: Long): String? {
|
||||
return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
|
||||
}
|
||||
|
||||
fun setCoverEntry(name: String) {
|
||||
json.put("cover_entry", name)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.copyToSuspending
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.utils.ext.subdir
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfWriteable
|
||||
@@ -20,47 +23,41 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val cacheDir = checkNotNull(findSuitableDir(context)) {
|
||||
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") {
|
||||
it?.absolutePath.toString()
|
||||
private val cacheDir = SuspendLazy {
|
||||
val dirs = context.externalCacheDirs + context.cacheDir
|
||||
dirs.firstNotNullOf {
|
||||
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
||||
}
|
||||
"Cannot find any suitable directory for PagesCache: [$dirs]"
|
||||
}
|
||||
private val lruCache = createDiskLruCacheSafe(
|
||||
dir = cacheDir,
|
||||
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
|
||||
)
|
||||
private val lruCache = SuspendLazy {
|
||||
val dir = cacheDir.get()
|
||||
val size = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
|
||||
runCatchingCancellable {
|
||||
DiskLruCache.create(dir, size)
|
||||
}.recoverCatching { error ->
|
||||
error.printStackTraceDebug()
|
||||
dir.deleteRecursively()
|
||||
dir.mkdir()
|
||||
DiskLruCache.create(dir, size)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun get(url: String): File? = runInterruptible(Dispatchers.IO) {
|
||||
lruCache.get(url)?.takeIfReadable()
|
||||
suspend fun get(url: String): File? {
|
||||
val cache = lruCache.get()
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
cache.get(url)?.takeIfReadable()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
|
||||
val file = File(cacheDir.parentFile, url.longHashCode().toString())
|
||||
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||
try {
|
||||
file.outputStream().use { out ->
|
||||
inputStream.copyToSuspending(out)
|
||||
}
|
||||
lruCache.put(url, file)
|
||||
lruCache.get().put(url, file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
|
||||
return try {
|
||||
DiskLruCache.create(dir, size)
|
||||
} catch (e: Exception) {
|
||||
dir.deleteRecursively()
|
||||
dir.mkdir()
|
||||
DiskLruCache.create(dir, size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findSuitableDir(context: Context): File? {
|
||||
val dirs = context.externalCacheDirs + context.cacheDir
|
||||
return dirs.firstNotNullOfOrNull {
|
||||
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class LocalStorageChanges
|
||||
@@ -6,11 +6,13 @@ import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
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.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.utils.ext.copyToSuspending
|
||||
@@ -23,16 +25,19 @@ import javax.inject.Inject
|
||||
class SingleMangaImporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val storageManager: LocalStorageManager,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
) {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
suspend fun import(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {
|
||||
return if (isDirectory(uri)) {
|
||||
val result = if (isDirectory(uri)) {
|
||||
importDirectory(uri, progressState)
|
||||
} else {
|
||||
importFile(uri, progressState)
|
||||
}
|
||||
localStorageChanges.emit(result)
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun importFile(uri: Uri, progressState: MutableStateFlow<Float>?): LocalManga {
|
||||
|
||||
@@ -54,13 +54,14 @@ class LocalMangaDirOutput(
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
output.put(name, file)
|
||||
}
|
||||
index.addChapter(chapter)
|
||||
index.addChapter(chapter, chapterFileName(chapter))
|
||||
}
|
||||
|
||||
override suspend fun flushChapter(chapter: MangaChapter) {
|
||||
val output = chaptersOutput.remove(chapter) ?: return
|
||||
override suspend fun flushChapter(chapter: MangaChapter): Boolean {
|
||||
val output = chaptersOutput.remove(chapter) ?: return false
|
||||
output.flushAndFinish()
|
||||
flushIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun finish() {
|
||||
@@ -104,7 +105,18 @@ class LocalMangaDirOutput(
|
||||
}
|
||||
|
||||
private fun chapterFileName(chapter: MangaChapter): String {
|
||||
return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz"
|
||||
index.getChapterFileName(chapter.id)?.let {
|
||||
return it
|
||||
}
|
||||
val baseName = "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18)
|
||||
var i = 0
|
||||
while (true) {
|
||||
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
|
||||
if (!File(rootFile, name).exists()) {
|
||||
return name
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
|
||||
|
||||
@@ -16,7 +16,7 @@ sealed class LocalMangaOutput(
|
||||
|
||||
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
|
||||
|
||||
abstract suspend fun flushChapter(chapter: MangaChapter)
|
||||
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
|
||||
|
||||
abstract suspend fun finish()
|
||||
|
||||
|
||||
@@ -57,10 +57,10 @@ class LocalMangaZipOutput(
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
output.put(name, file)
|
||||
}
|
||||
index.addChapter(chapter)
|
||||
index.addChapter(chapter, null)
|
||||
}
|
||||
|
||||
override suspend fun flushChapter(chapter: MangaChapter) = Unit
|
||||
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
|
||||
|
||||
override suspend fun finish() {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
@@ -98,7 +98,7 @@ class LocalMangaZipOutput(
|
||||
}
|
||||
otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
|
||||
for (chapter in chapters) {
|
||||
index.addChapter(chapter)
|
||||
index.addChapter(chapter, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data.util
|
||||
|
||||
import android.os.Build
|
||||
import android.os.FileObserver
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.ProducerScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.io.File
|
||||
|
||||
fun File.observe() = callbackFlow {
|
||||
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
FlowFileObserverQ(this, this@observe)
|
||||
} else {
|
||||
FlowFileObserver(this, this@observe)
|
||||
}
|
||||
observer.startWatching()
|
||||
awaitClose { observer.stopWatching() }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private class FlowFileObserverQ(
|
||||
private val producerScope: ProducerScope<File>,
|
||||
private val file: File,
|
||||
) : FileObserver(file, CREATE or DELETE or CLOSE_WRITE) {
|
||||
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
producerScope.trySendBlocking(
|
||||
if (path == null) file else file.resolve(path),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private class FlowFileObserver(
|
||||
private val producerScope: ProducerScope<File>,
|
||||
private val file: File,
|
||||
) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) {
|
||||
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
producerScope.trySendBlocking(
|
||||
if (path == null) file else file.resolve(path),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
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.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
@@ -34,7 +37,10 @@ import javax.inject.Singleton
|
||||
private const val MAX_PARALLELISM = 4
|
||||
|
||||
@Singleton
|
||||
class LocalMangaRepository @Inject constructor(private val storageManager: LocalStorageManager) : MangaRepository {
|
||||
class LocalMangaRepository @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
) : MangaRepository {
|
||||
|
||||
override val source = MangaSource.LOCAL
|
||||
private val locks = CompositeMutex<Long>()
|
||||
@@ -84,13 +90,18 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
|
||||
|
||||
suspend fun delete(manga: Manga): Boolean {
|
||||
val file = Uri.parse(manga.url).toFile()
|
||||
return file.deleteAwait()
|
||||
val result = file.deleteAwait()
|
||||
if (result) {
|
||||
localStorageChanges.emit(null)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
|
||||
lockManga(manga.id)
|
||||
try {
|
||||
LocalMangaUtil(manga).deleteChapters(ids)
|
||||
localStorageChanges.emit(LocalManga(manga))
|
||||
} finally {
|
||||
unlockManga(manga.id)
|
||||
}
|
||||
@@ -106,21 +117,24 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
|
||||
val files = getAllFiles()
|
||||
val input = files.firstNotNullOfOrNull { file ->
|
||||
LocalMangaInput.of(file).takeIf {
|
||||
runCatchingCancellable {
|
||||
it.getMangaInfo()
|
||||
}.getOrNull()?.id == remoteManga.id
|
||||
}
|
||||
if (files.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return input?.getManga()
|
||||
}
|
||||
|
||||
suspend fun watchReadableDirs(): Flow<File> {
|
||||
val filter = TempFileFilter()
|
||||
val dirs = storageManager.getReadableDirs()
|
||||
return storageManager.observe(dirs)
|
||||
.filterNot { filter.accept(it, it.name) }
|
||||
return channelFlow {
|
||||
for (file in files) {
|
||||
launch {
|
||||
val mangaInput = LocalMangaInput.of(file)
|
||||
runCatchingCancellable {
|
||||
val mangaInfo = mangaInput.getMangaInfo()
|
||||
if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
|
||||
send(mangaInput)
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.firstOrNull()?.getManga()
|
||||
}
|
||||
|
||||
override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
|
||||
@@ -149,7 +163,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
|
||||
dirs.flatMap { dir ->
|
||||
dir.listFiles(TempFileFilter())?.toList().orEmpty()
|
||||
}.forEach { file ->
|
||||
file.delete()
|
||||
file.deleteRecursively()
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
@@ -27,7 +28,6 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||
@@ -99,11 +99,12 @@ class ImportWorker @AssistedInject constructor(
|
||||
notification.setSubText(manga.title)
|
||||
val intent = DetailsActivity.newIntent(applicationContext, manga)
|
||||
notification.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
manga.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
),
|
||||
).setAutoCancel(true)
|
||||
.setVisibility(
|
||||
|
||||
@@ -9,10 +9,12 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
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.utils.ext.getDisplayMessage
|
||||
@@ -25,6 +27,10 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
@Inject
|
||||
lateinit var localMangaRepository: LocalMangaRepository
|
||||
|
||||
@Inject
|
||||
@LocalStorageChanges
|
||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
@@ -41,10 +47,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
startForeground()
|
||||
val mangaWithChapters = localMangaRepository.getDetails(manga)
|
||||
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
|
||||
sendBroadcast(
|
||||
Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)),
|
||||
)
|
||||
localStorageChanges.emit(LocalManga(manga))
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
|
||||
@@ -27,6 +26,8 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader2
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
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.MangaTag
|
||||
@@ -46,6 +47,7 @@ class LocalListViewModel @Inject constructor(
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val settings: AppSettings,
|
||||
private val tagHighlighter: MangaTagHighlighter,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) : MangaListViewModel(settings), ListExtraProvider {
|
||||
|
||||
val onMangaRemoved = SingleLiveEvent<Unit>()
|
||||
@@ -83,7 +85,14 @@ class LocalListViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
onRefresh()
|
||||
watchDirectories()
|
||||
launchJob(Dispatchers.Default) {
|
||||
localStorageChanges
|
||||
.collectLatest {
|
||||
if (refreshJob?.isActive != true) {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) {
|
||||
@@ -108,21 +117,19 @@ class LocalListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun delete(ids: Set<Long>) {
|
||||
launchLoadingJob {
|
||||
withContext(Dispatchers.Default) {
|
||||
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
|
||||
for (manga in itemsToRemove) {
|
||||
val original = repository.getRemoteManga(manga)
|
||||
repository.delete(manga) || throw IOException("Unable to delete file")
|
||||
runCatchingCancellable {
|
||||
historyRepository.deleteOrSwap(manga, original)
|
||||
}
|
||||
mangaList.update { list ->
|
||||
list?.filterNot { it.id == manga.id }
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
|
||||
for (manga in itemsToRemove) {
|
||||
val original = repository.getRemoteManga(manga)
|
||||
repository.delete(manga) || throw IOException("Unable to delete file")
|
||||
runCatchingCancellable {
|
||||
historyRepository.deleteOrSwap(manga, original)
|
||||
}
|
||||
mangaList.update { list ->
|
||||
list?.filterNot { it.id == manga.id }
|
||||
}
|
||||
}
|
||||
onMangaRemoved.call(Unit)
|
||||
onMangaRemoved.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,15 +144,6 @@ class LocalListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchDirectories() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
repository.watchReadableDirs()
|
||||
.collectLatest {
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHeader(mangaList: List<Manga>, selectedTags: Set<MangaTag>, order: SortOrder): ListHeader2 {
|
||||
val tags = HashMap<MangaTag, Int>()
|
||||
for (item in mangaList) {
|
||||
|
||||
@@ -12,13 +12,12 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
|
||||
class ExitCallback(
|
||||
private val activity: BaseActivity<*>,
|
||||
private val activity: MainActivity,
|
||||
private val snackbarHost: View,
|
||||
) : OnBackPressedCallback(false) {
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
@@ -68,6 +69,7 @@ import org.koitharu.kotatsu.utils.ext.resolve
|
||||
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.tryLaunch
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val TAG_SEARCH = "search"
|
||||
@@ -82,6 +84,9 @@ class MainActivity :
|
||||
SearchSuggestionListener,
|
||||
MainNavigationDelegate.OnFragmentChangedListener {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val viewModel by viewModels<MainViewModel>()
|
||||
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
|
||||
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
|
||||
|
||||
@@ -60,9 +60,9 @@ class MainViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun openLastReader() {
|
||||
launchLoadingJob {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
|
||||
onOpenReader.call(manga)
|
||||
onOpenReader.emitCall(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.domain
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import dagger.hilt.android.ActivityRetainedLifecycle
|
||||
@@ -76,6 +77,7 @@ class PageLoader @Inject constructor(
|
||||
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun prefetch(pages: List<ReaderPage>) = loaderScope.launch {
|
||||
prefetchLock.withLock {
|
||||
for (page in pages.asReversed()) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -66,6 +67,9 @@ class ReaderActivity :
|
||||
OnApplyWindowInsetsListener,
|
||||
IdlingDetector.Callback {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
|
||||
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
|
||||
@@ -15,9 +15,6 @@ import androidx.annotation.AttrRes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.material.R as materialR
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
@@ -25,6 +22,9 @@ import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.utils.ext.measureDimension
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ReaderInfoBarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -121,15 +121,14 @@ class ReaderInfoBarView @JvmOverloads constructor(
|
||||
|
||||
fun update(state: ReaderUiState?) {
|
||||
text = if (state != null) {
|
||||
val percent = state.computePercent()
|
||||
context.getString(
|
||||
R.string.reader_info_pattern,
|
||||
state.chapterNumber,
|
||||
state.chaptersTotal,
|
||||
state.currentPage + 1,
|
||||
state.totalPages,
|
||||
) + if (percent in 0f..1f) {
|
||||
" " + context.getString(R.string.percent_string_pattern, (percent * 100).format())
|
||||
) + if (state.percent in 0f..1f) {
|
||||
" " + context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.net.Uri
|
||||
import android.util.LongSparseArray
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
@@ -13,6 +15,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -50,6 +53,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.utils.ext.requireValue
|
||||
@@ -80,6 +84,7 @@ class ReaderViewModel @Inject constructor(
|
||||
private var loadingJob: Job? = null
|
||||
private var pageSaveJob: Job? = null
|
||||
private var bookmarkJob: Job? = null
|
||||
private var stateChangeJob: Job? = null
|
||||
private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
|
||||
private val mangaData = MutableStateFlow(intent.manga)
|
||||
private val chapters: LongSparseArray<MangaChapter>
|
||||
@@ -143,7 +148,7 @@ class ReaderViewModel @Inject constructor(
|
||||
settings.observe()
|
||||
.onEach { key ->
|
||||
if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged()
|
||||
}.launchIn(viewModelScope)
|
||||
}.launchIn(viewModelScope + Dispatchers.Default)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
@@ -198,12 +203,12 @@ class ReaderViewModel @Inject constructor(
|
||||
prevJob?.cancelAndJoin()
|
||||
try {
|
||||
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
|
||||
onPageSaved.postCall(dest)
|
||||
onPageSaved.emitCall(dest)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
onPageSaved.postCall(null)
|
||||
onPageSaved.emitCall(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,26 +239,31 @@ class ReaderViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO move to background?
|
||||
@MainThread
|
||||
fun onCurrentPageChanged(position: Int) {
|
||||
val pages = content.value?.pages ?: return
|
||||
pages.getOrNull(position)?.let { page ->
|
||||
currentState.update { cs ->
|
||||
cs?.copy(chapterId = page.chapterId, page = page.index)
|
||||
val prevJob = stateChangeJob
|
||||
stateChangeJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val pages = content.value?.pages ?: return@launchJob
|
||||
pages.getOrNull(position)?.let { page ->
|
||||
currentState.update { cs ->
|
||||
cs?.copy(chapterId = page.chapterId, page = page.index)
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
if (pages.isEmpty() || loadingJob?.isActive == true) {
|
||||
return@launchJob
|
||||
}
|
||||
ensureActive()
|
||||
if (position <= BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.first().chapterId, isNext = false)
|
||||
}
|
||||
if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.last().chapterId, isNext = true)
|
||||
}
|
||||
if (pageLoader.isPrefetchApplicable()) {
|
||||
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
if (pages.isEmpty() || loadingJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
if (position <= BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.first().chapterId, isNext = false)
|
||||
}
|
||||
if (position >= pages.size - BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.last().chapterId, isNext = true)
|
||||
}
|
||||
if (pageLoader.isPrefetchApplicable()) {
|
||||
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +286,7 @@ class ReaderViewModel @Inject constructor(
|
||||
percent = computePercent(state.chapterId, state.page),
|
||||
)
|
||||
bookmarksRepository.addBookmark(bookmark)
|
||||
onShowToast.postCall(R.string.bookmark_added)
|
||||
onShowToast.emitCall(R.string.bookmark_added)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +323,7 @@ class ReaderViewModel @Inject constructor(
|
||||
|
||||
val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch
|
||||
mangaData.value = manga.filterChapters(branch)
|
||||
readerMode.postValue(mode)
|
||||
readerMode.emitValue(mode)
|
||||
|
||||
chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId)
|
||||
// save state
|
||||
@@ -324,14 +334,15 @@ class ReaderViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
content.postValue(ReaderContent(chaptersLoader.snapshot(), currentState.value))
|
||||
content.emitValue(ReaderContent(chaptersLoader.snapshot(), currentState.value))
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
|
||||
content.postValue(ReaderContent(chaptersLoader.snapshot(), null))
|
||||
content.emitValue(ReaderContent(chaptersLoader.snapshot(), null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +376,7 @@ class ReaderViewModel @Inject constructor(
|
||||
}.getOrDefault(defaultMode)
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
@WorkerThread
|
||||
private fun notifyStateChanged() {
|
||||
val state = getCurrentState()
|
||||
val chapter = state?.chapterId?.let(chapters::get)
|
||||
@@ -373,10 +384,11 @@ class ReaderViewModel @Inject constructor(
|
||||
mangaName = manga?.title,
|
||||
chapterName = chapter?.name,
|
||||
chapterNumber = chapter?.number ?: 0,
|
||||
chaptersTotal = chapters.size(),
|
||||
chaptersTotal = manga?.getChapters(chapter?.branch)?.size ?: 0,
|
||||
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
|
||||
currentPage = state?.page ?: 0,
|
||||
isSliderEnabled = settings.isReaderSliderEnabled,
|
||||
percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE,
|
||||
)
|
||||
uiState.postValue(newState)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import org.koitharu.kotatsu.utils.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.utils.ext.indicator
|
||||
import org.koitharu.kotatsu.utils.ext.setValueRounded
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
@@ -110,6 +111,7 @@ class ColorFilterConfigActivity :
|
||||
.scale(Scale.FILL)
|
||||
.decodeRegion()
|
||||
.tag(preview.source)
|
||||
.indicator(listOf(binding.progressBefore, binding.progressAfter))
|
||||
.error(R.drawable.ic_error_placeholder)
|
||||
.size(ViewSizeResolver(binding.imageViewBefore))
|
||||
.allowRgb565(false)
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -43,7 +44,7 @@ class ColorFilterConfigViewModel @Inject constructor(
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val repository = mangaRepositoryFactory.create(page.source)
|
||||
val url = repository.getPageUrl(page)
|
||||
preview.postValue(
|
||||
preview.emitValue(
|
||||
MangaPage(
|
||||
id = page.id,
|
||||
url = url,
|
||||
@@ -71,7 +72,7 @@ class ColorFilterConfigViewModel @Inject constructor(
|
||||
fun save() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
mangaDataRepository.saveColorFilter(manga, colorFilter.value)
|
||||
onDismiss.postCall(Unit)
|
||||
onDismiss.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,11 @@ data class ReaderUiState(
|
||||
val chaptersTotal: Int,
|
||||
val currentPage: Int,
|
||||
val totalPages: Int,
|
||||
val percent: Float,
|
||||
private val isSliderEnabled: Boolean,
|
||||
) {
|
||||
|
||||
fun isSliderAvailable(): Boolean {
|
||||
return isSliderEnabled && totalPages > 1 && currentPage < totalPages
|
||||
}
|
||||
|
||||
fun computePercent(): Float {
|
||||
val ppc = 1f / chaptersTotal
|
||||
val chapterIndex = chapterNumber - 1
|
||||
val pagePercent = (currentPage + 1) / totalPages.toFloat()
|
||||
return ppc * chapterIndex + ppc * pagePercent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ class RemoteListViewModel @Inject constructor(
|
||||
e.printStackTraceDebug()
|
||||
listError.value = e
|
||||
if (!mangaList.value.isNullOrEmpty()) {
|
||||
errorEvent.postCall(e)
|
||||
errorEvent.emitCall(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import org.koitharu.kotatsu.utils.ext.onFirst
|
||||
import org.koitharu.kotatsu.utils.ext.require
|
||||
import javax.inject.Inject
|
||||
@@ -51,22 +52,22 @@ class ScrobblerConfigViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
scrobbler.user
|
||||
.onEach { user.postValue(it) }
|
||||
.onEach { user.emitValue(it) }
|
||||
.launchIn(viewModelScope + Dispatchers.Default)
|
||||
}
|
||||
|
||||
fun onAuthCodeReceived(authCode: String) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val newUser = scrobbler.authorize(authCode)
|
||||
user.postValue(newUser)
|
||||
user.emitValue(newUser)
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
scrobbler.logout()
|
||||
user.postValue(null)
|
||||
onLoggedOut.postCall(Unit)
|
||||
user.emitValue(null)
|
||||
onLoggedOut.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.require
|
||||
import org.koitharu.kotatsu.utils.ext.requireValue
|
||||
@@ -135,7 +136,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
|
||||
}
|
||||
doneJob = launchJob(Dispatchers.Default) {
|
||||
currentScrobbler.linkManga(manga.id, targetId)
|
||||
onClose.postCall(Unit)
|
||||
onClose.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
|
||||
try {
|
||||
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id)
|
||||
if (info != null) {
|
||||
selectedItemId.postValue(info.targetId)
|
||||
selectedItemId.emitValue(info.targetId)
|
||||
}
|
||||
} finally {
|
||||
loadList(append = false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.scrobbling.mal.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
@@ -20,7 +21,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
|
||||
import org.koitharu.kotatsu.utils.PKCEGenerator
|
||||
import java.security.SecureRandom
|
||||
|
||||
private const val REDIRECT_URI = "kotatsu://mal-auth"
|
||||
private const val BASE_WEB_URL = "https://myanimelist.net"
|
||||
@@ -35,7 +36,7 @@ class MALRepository(
|
||||
) : ScrobblerRepository {
|
||||
|
||||
private val clientId = context.getString(R.string.mal_clientId)
|
||||
private var codeVerifier: String = getPKCEChallengeCode()
|
||||
private val codeVerifier: String by lazy(::generateCodeVerifier)
|
||||
|
||||
override val oauthUrl: String
|
||||
get() = "$BASE_WEB_URL/v1/oauth2/authorize?" +
|
||||
@@ -177,11 +178,6 @@ class MALRepository(
|
||||
storage.clear()
|
||||
}
|
||||
|
||||
private fun getPKCEChallengeCode(): String {
|
||||
codeVerifier = PKCEGenerator.generateCodeVerifier()
|
||||
return codeVerifier
|
||||
}
|
||||
|
||||
private fun jsonToManga(json: JSONObject): ScrobblerManga? {
|
||||
for (i in 0 until json.length()) {
|
||||
val node = json.getJSONObject("node")
|
||||
@@ -210,4 +206,10 @@ class MALRepository(
|
||||
avatar = json.getString("picture") ?: AVATAR_STUB,
|
||||
service = ScrobblerService.MAL,
|
||||
)
|
||||
|
||||
private fun generateCodeVerifier(): String {
|
||||
val codeVerifier = ByteArray(50)
|
||||
SecureRandom().nextBytes(codeVerifier)
|
||||
return Base64.encodeToString(codeVerifier, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.annotation.SuppressLint
|
||||
import android.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -22,9 +22,9 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import javax.inject.Singleton
|
||||
import javax.inject.Inject
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class MangaSearchRepository @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val db: MangaDatabase,
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
@@ -45,6 +46,9 @@ class MultiSearchActivity :
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val viewModel by viewModels<MultiSearchViewModel>()
|
||||
private lateinit var adapter: MultiSearchAdapter
|
||||
private lateinit var selectionController: ListSelectionController
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.asFlowLiveData
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -96,7 +97,7 @@ class MultiSearchViewModel @Inject constructor(
|
||||
listError.value = null
|
||||
listData.value = emptyList()
|
||||
loadingData.value = true
|
||||
query.postValue(q)
|
||||
query.emitValue(q)
|
||||
searchImpl(q)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val DEBOUNCE_TIMEOUT = 500L
|
||||
@@ -97,7 +98,7 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
buildSearchSuggestion(searchQuery, hiddenSources)
|
||||
}.distinctUntilChanged()
|
||||
.onEach {
|
||||
suggestion.postValue(it)
|
||||
suggestion.emitValue(it)
|
||||
}.launchIn(viewModelScope + Dispatchers.Default)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
@@ -66,13 +66,13 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onProgressChanged(progress: Progress?) {
|
||||
private fun onProgressChanged(value: Float) {
|
||||
with(binding.progressBar) {
|
||||
isIndeterminate = progress == null
|
||||
isVisible = true
|
||||
if (progress != null) {
|
||||
this.max = progress.total
|
||||
this.progress = progress.value
|
||||
val wasIndeterminate = isIndeterminate
|
||||
isIndeterminate = value < 0
|
||||
if (value >= 0) {
|
||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BackupViewModel @Inject constructor(
|
||||
@@ -18,7 +17,7 @@ class BackupViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val progress = MutableLiveData<Progress?>(null)
|
||||
val progress = MutableLiveData(-1f)
|
||||
val onBackupDone = SingleLiveEvent<File>()
|
||||
|
||||
init {
|
||||
@@ -26,18 +25,18 @@ class BackupViewModel @Inject constructor(
|
||||
val file = BackupZipOutput(context).use { backup ->
|
||||
backup.put(repository.createIndex())
|
||||
|
||||
progress.value = Progress(0, 3)
|
||||
progress.value = 0f
|
||||
backup.put(repository.dumpHistory())
|
||||
|
||||
progress.value = Progress(1, 3)
|
||||
progress.value = 0.3f
|
||||
backup.put(repository.dumpCategories())
|
||||
|
||||
progress.value = Progress(2, 3)
|
||||
progress.value = 0.6f
|
||||
backup.put(repository.dumpFavourites())
|
||||
|
||||
progress.value = Progress(3, 3)
|
||||
progress.value = 0.9f
|
||||
backup.finish()
|
||||
progress.value = null
|
||||
progress.value = 1f
|
||||
backup.close()
|
||||
backup.file
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
@@ -51,13 +51,13 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onProgressChanged(progress: Progress?) {
|
||||
private fun onProgressChanged(value: Float) {
|
||||
with(binding.progressBar) {
|
||||
isVisible = true
|
||||
isIndeterminate = progress == null
|
||||
if (progress != null) {
|
||||
this.max = progress.total
|
||||
this.progress = progress.value
|
||||
val wasIndeterminate = isIndeterminate
|
||||
isIndeterminate = value < 0
|
||||
if (value >= 0) {
|
||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import javax.inject.Inject
|
||||
@@ -26,7 +25,7 @@ class RestoreViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val progress = MutableLiveData<Progress?>(null)
|
||||
val progress = MutableLiveData(-1f)
|
||||
val onRestoreDone = SingleLiveEvent<CompositeResult>()
|
||||
|
||||
init {
|
||||
@@ -47,16 +46,16 @@ class RestoreViewModel @Inject constructor(
|
||||
try {
|
||||
val result = CompositeResult()
|
||||
|
||||
progress.value = Progress(0, 3)
|
||||
progress.value = 0f
|
||||
result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||
|
||||
progress.value = Progress(1, 3)
|
||||
progress.value = 0.3f
|
||||
result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||
|
||||
progress.value = Progress(2, 3)
|
||||
progress.value = 0.6f
|
||||
result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||
|
||||
progress.value = Progress(3, 3)
|
||||
progress.value = 1f
|
||||
onRestoreDone.call(result)
|
||||
} finally {
|
||||
backup.close()
|
||||
|
||||
@@ -82,7 +82,7 @@ class SourcesListViewModel @Inject constructor(
|
||||
val rollback = ReversibleHandle {
|
||||
setEnabled(source, true)
|
||||
}
|
||||
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback))
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
import org.koitharu.kotatsu.core.db.removeObserverAsync
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.emitValue
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -31,7 +32,7 @@ class TrackerSettingsViewModel @Inject constructor(
|
||||
|
||||
private fun updateCategoriesCount() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
categoriesCount.postValue(repository.getCategoriesCount())
|
||||
categoriesCount.emitValue(repository.getCategoriesCount())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package org.koitharu.kotatsu.shelf.domain
|
||||
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
@@ -18,20 +20,22 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
|
||||
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.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class ShelfRepository @Inject constructor(
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val db: MangaDatabase,
|
||||
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
|
||||
) {
|
||||
|
||||
fun observeShelfContent(): Flow<ShelfContent> = combine(
|
||||
@@ -43,16 +47,15 @@ class ShelfRepository @Inject constructor(
|
||||
ShelfContent(history, favorites, updated, local)
|
||||
}
|
||||
|
||||
fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
|
||||
return flow {
|
||||
emit(null)
|
||||
emitAll(localMangaRepository.watchReadableDirs())
|
||||
}.mapLatest {
|
||||
localMangaRepository.getList(0, null, sortOrder)
|
||||
}
|
||||
private fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
|
||||
return localStorageChanges
|
||||
.onStart { emit(null) }
|
||||
.mapLatest {
|
||||
localMangaRepository.getList(0, null, sortOrder)
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
|
||||
private fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
|
||||
return db.favouriteCategoriesDao.observeAll()
|
||||
.flatMapLatest { categories ->
|
||||
val cats = categories.filter { it.isVisibleInLibrary }
|
||||
|
||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
|
||||
@@ -60,10 +59,9 @@ class ShelfViewModel @Inject constructor(
|
||||
repository.observeShelfContent(),
|
||||
) { sections, isTrackerEnabled, isConnected, content ->
|
||||
mapList(content, isTrackerEnabled, sections, isConnected)
|
||||
}.debounce(500)
|
||||
.catch { e ->
|
||||
emit(listOf(e.toErrorState(canRetry = false)))
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
}.catch { e ->
|
||||
emit(listOf(e.toErrorState(canRetry = false)))
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
@@ -93,7 +91,7 @@ class ShelfViewModel @Inject constructor(
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
val handle = favouritesRepository.removeFromCategory(category.id, ids)
|
||||
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.removed_from_favourites, handle))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,14 +101,14 @@ class ShelfViewModel @Inject constructor(
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
val handle = historyRepository.delete(ids)
|
||||
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.removed_from_history, handle))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocal(ids: Set<Long>) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
repository.deleteLocalManga(ids)
|
||||
onActionDone.postCall(ReversibleAction(R.string.removal_completed, null))
|
||||
onActionDone.emitCall(ReversibleAction(R.string.removal_completed, null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +121,7 @@ class ShelfViewModel @Inject constructor(
|
||||
historyRepository.deleteAfter(minDate)
|
||||
R.string.removed_from_history
|
||||
}
|
||||
onActionDone.postCall(ReversibleAction(stringRes, null))
|
||||
onActionDone.emitCall(ReversibleAction(stringRes, null))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class ShelfAdapter(
|
||||
)
|
||||
.addDelegate(loadingStateAD())
|
||||
.addDelegate(loadingFooterAD())
|
||||
.addDelegate(emptyHintAD(listener))
|
||||
.addDelegate(emptyHintAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(errorStateListAD(listener))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package org.koitharu.kotatsu.sync.ui
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
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(
|
||||
@@ -19,7 +19,7 @@ class SyncAuthViewModel @Inject constructor(
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val token = api.authenticate(email, password)
|
||||
val result = SyncAuthResult(email, password, token)
|
||||
onTokenObtained.postCall(result)
|
||||
onTokenObtained.emitCall(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.tracker.domain
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -23,11 +24,10 @@ import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
|
||||
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val NO_ID = 0L
|
||||
|
||||
@Singleton
|
||||
@Reusable
|
||||
class TrackingRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
@@ -56,7 +56,7 @@ class FeedViewModel @Inject constructor(
|
||||
if (clearCounters) {
|
||||
repository.clearCounters()
|
||||
}
|
||||
onFeedCleared.postCall(Unit)
|
||||
onFeedCleared.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.lifecycle.LiveData
|
||||
@@ -45,7 +46,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.tracker.domain.Tracker
|
||||
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
|
||||
import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.trySetForeground
|
||||
@@ -170,11 +170,12 @@ class TrackWorker @AssistedInject constructor(
|
||||
setStyle(style)
|
||||
val intent = DetailsActivity.newIntent(applicationContext, manga)
|
||||
setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
),
|
||||
)
|
||||
setAutoCancel(true)
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.LinkedList
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class CompositeMutex<T : Any> : Set<T> {
|
||||
|
||||
private val data = ArrayMap<T, MutableList<CancellableContinuation<Unit>>>()
|
||||
private val state = ArrayMap<T, MutableStateFlow<Boolean>>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override val size: Int
|
||||
get() = data.size
|
||||
get() = state.size
|
||||
|
||||
override fun contains(element: T): Boolean {
|
||||
return data.containsKey(element)
|
||||
return state.containsKey(element)
|
||||
}
|
||||
|
||||
override fun containsAll(elements: Collection<T>): Boolean {
|
||||
return elements.all { x -> data.containsKey(x) }
|
||||
return elements.all { x -> state.containsKey(x) }
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean {
|
||||
return data.isEmpty
|
||||
return state.isEmpty
|
||||
}
|
||||
|
||||
override fun iterator(): Iterator<T> {
|
||||
return data.keys.iterator()
|
||||
return state.keys.iterator()
|
||||
}
|
||||
|
||||
suspend fun lock(element: T) {
|
||||
while (coroutineContext.isActive) {
|
||||
waitForRemoval(element)
|
||||
mutex.withLock {
|
||||
if (data[element] == null) {
|
||||
data[element] = LinkedList<CancellableContinuation<Unit>>()
|
||||
if (state[element] == null) {
|
||||
state[element] = MutableStateFlow(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -47,23 +45,13 @@ class CompositeMutex<T : Any> : Set<T> {
|
||||
}
|
||||
|
||||
fun unlock(element: T) {
|
||||
val continuations = checkNotNull(data.remove(element)) {
|
||||
checkNotNull(state.remove(element)) {
|
||||
"CompositeMutex is not locked for $element"
|
||||
}
|
||||
continuations.forEach { c ->
|
||||
if (c.isActive) {
|
||||
c.resume(Unit)
|
||||
}
|
||||
}
|
||||
}.value = true
|
||||
}
|
||||
|
||||
private suspend fun waitForRemoval(element: T) {
|
||||
val list = data[element] ?: return
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
list.add(continuation)
|
||||
continuation.invokeOnCancellation {
|
||||
list.remove(continuation)
|
||||
}
|
||||
}
|
||||
val flow = state[element] ?: return
|
||||
flow.first { it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlinx.coroutines.*
|
||||
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
|
||||
|
||||
@@ -51,11 +57,16 @@ class FlowLiveData<T>(
|
||||
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
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
||||
withContext(dispatcher) {
|
||||
setValue(value)
|
||||
}
|
||||
} else {
|
||||
setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
|
||||
object InternalResourceHelper {
|
||||
|
||||
fun getBoolean(context: Context, resName: String, defaultValue: Boolean): Boolean {
|
||||
val id = getResourceId(resName, "bool")
|
||||
return if (id != 0) {
|
||||
context.createPackageContext("android", 0).resources.getBoolean(id)
|
||||
} else {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource id from system resources
|
||||
* @param resName resource name to get
|
||||
* @param type resource type of [resName] to get
|
||||
* @return 0 if not available
|
||||
*/
|
||||
private fun getResourceId(resName: String, type: String): Int {
|
||||
return Resources.getSystem().getIdentifier(resName, type, "android")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.SecureRandom
|
||||
|
||||
object PKCEGenerator {
|
||||
|
||||
private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
|
||||
|
||||
fun generateCodeVerifier(): String {
|
||||
val codeVerifier = ByteArray(50)
|
||||
SecureRandom().nextBytes(codeVerifier)
|
||||
return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS)
|
||||
}
|
||||
|
||||
}
|
||||