Compare commits

...

18 Commits

Author SHA1 Message Date
3fd6bec433 Update parsers 2023-04-28 17:24:21 +03:00
262e26a0cc Fix crashes 2023-04-28 17:23:29 +03:00
1b64c2a330 Update parsers 2023-04-28 17:23:29 +03:00
5ea0ecbd12 Permormance improvements 2023-04-28 17:23:29 +03:00
Dawid Jarubas
f9a1d1617e Translated using Weblate (Polish)
Currently translated at 91.4% (398 of 435 strings)

Co-authored-by: Dawid Jarubas <jarubas.dawid@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2023-04-28 17:22:58 +03:00
gallegonovato
10d8365fc1 Translated using Weblate (Spanish)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-28 17:22:58 +03:00
85065c57a1 Translated using Weblate (Russian)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.4% (415 of 435 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
GpixeL
75e130b97c Translated using Weblate (Indonesian)
Currently translated at 97.6% (424 of 434 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Luiz-bro
df99eec429 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
Allan Nordhøy
3a40820991 Translated using Weblate (Norwegian Bokmål)
Currently translated at 78.1% (339 of 434 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
gallegonovato
27bd2e74ca Translated using Weblate (Spanish)
Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-04-26 15:55:27 +03:00
a308688a2e Misc ui fixes 2023-04-26 14:48:49 +03:00
259c845912 Fix chapters counter 2023-04-26 09:45:01 +03:00
a89ff4d15d Refactoring 2023-04-23 16:25:31 +03:00
3ed9ed8cab Fix text on sync authorization activity 2023-04-22 15:25:57 +03:00
40b9577e69 Reduce number of @Singleton instances 2023-04-22 14:56:23 +03:00
b87ae19712 Update parsers 2023-04-20 18:10:42 +03:00
1dc7e61dbd Migrate to PendingIntentCompat 2023-04-20 18:09:24 +03:00
114 changed files with 1546 additions and 1496 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 537
versionName '5.0-rc2'
versionCode 539
versionName '5.0.1'
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:3e349d3db3') {
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'

View File

@@ -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,

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}
}
}

View File

@@ -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)) {

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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,
) {

View File

@@ -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))
}
}
}

View File

@@ -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()
}
}

View File

@@ -5,5 +5,5 @@ import okio.IOException
class CloudFlareProtectedException(
val url: String,
val headers: Headers,
@Transient val headers: Headers,
) : IOException("Protected by CloudFlare")

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.core.exceptions
class WrongPasswordException : SecurityException()
class WrongPasswordException : IllegalArgumentException()

View File

@@ -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
}
}

View File

@@ -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" })

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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>()

View File

@@ -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()) {

View File

@@ -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
@@ -58,6 +61,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(
@@ -165,13 +169,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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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))
}
}

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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))
}
}

View File

@@ -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,

View File

@@ -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))
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -1 +0,0 @@
package org.koitharu.kotatsu.local

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.local.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalStorageChanges

View File

@@ -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 {

View File

@@ -57,10 +57,11 @@ class LocalMangaDirOutput(
index.addChapter(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() {

View File

@@ -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()

View File

@@ -60,7 +60,7 @@ class LocalMangaZipOutput(
index.addChapter(chapter)
}
override suspend fun flushChapter(chapter: MangaChapter) = Unit
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() {
runInterruptible(Dispatchers.IO) {

View File

@@ -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),
)
}
}

View File

@@ -6,8 +6,9 @@ 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.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
@@ -106,21 +107,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 +153,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

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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())

View File

@@ -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)
}
}
}

View File

@@ -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()) {

View File

@@ -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()

View File

@@ -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 {
""
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -161,7 +161,7 @@ class RemoteListViewModel @Inject constructor(
e.printStackTraceDebug()
listError.value = e
if (!mangaList.value.isNullOrEmpty()) {
errorEvent.postCall(e)
errorEvent.emitCall(e)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View 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)
}
}
}

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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())
}
}

View File

@@ -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 }

View File

@@ -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))
}
}

View File

@@ -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))
}

View File

@@ -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)
}
}
}

View File

@@ -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,
) {

View File

@@ -56,7 +56,7 @@ class FeedViewModel @Inject constructor(
if (clearCounters) {
repository.clearCounters()
}
onFeedCleared.postCall(Unit)
onFeedCleared.emitCall(Unit)
}
}

View File

@@ -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)

View File

@@ -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 }
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.utils
import android.app.PendingIntent
import android.os.Build
object PendingIntentCompat {
@JvmField
val FLAG_IMMUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
@JvmField
val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
}

View File

@@ -5,7 +5,10 @@ import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.EmptyCoroutineContext
class SingleLiveEvent<T> : LiveData<T>() {
@@ -33,4 +36,15 @@ class SingleLiveEvent<T> : LiveData<T>() {
fun postCall(newValue: T) {
postValue(newValue)
}
}
suspend fun emitCall(newValue: T) {
val dispatcher = Dispatchers.Main.immediate
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
setValue(newValue)
}
} else {
setValue(newValue)
}
}
}

View File

@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.utils
import android.view.View
import androidx.annotation.OptIn
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils
import com.google.android.material.badge.ExperimentalBadgeUtils
@OptIn(ExperimentalBadgeUtils::class)
class ViewBadge(
private val anchor: View,
lifecycleOwner: LifecycleOwner,

View File

@@ -40,7 +40,6 @@ import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.InternalResourceHelper
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import kotlin.math.roundToLong
@@ -108,7 +107,7 @@ fun SyncResult.onError(error: Throwable) {
fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float, alphaFactor: Float = 0.7f) {
navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!InternalResourceHelper.getBoolean(context, "config_navBarNeedsScrim", true)
!context.getSystemBoolean("config_navBarNeedsScrim", true)
) {
Color.TRANSPARENT
} else {

View File

@@ -53,7 +53,11 @@ fun ImageResult.toBitmapOrNull() = when (this) {
}
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicator))
return listener(ImageRequestIndicatorListener(listOf(indicator)))
}
fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicators))
}
fun ImageRequest.Builder.decodeRegion(

View File

@@ -2,7 +2,11 @@ package org.koitharu.kotatsu.utils.ext
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.utils.BufferedObserver
import kotlin.coroutines.EmptyCoroutineContext
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
"LiveData value is null"
@@ -15,3 +19,14 @@ fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: Buffere
previous = it
}
}
suspend fun <T> MutableLiveData<T>.emitValue(newValue: T) {
val dispatcher = Dispatchers.Main.immediate
if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
withContext(dispatcher) {
value = newValue
}
} else {
value = newValue
}
}

View File

@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.utils.ext
import android.icu.lang.UCharacter.GraphemeClusterBreak.T
@Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) {
return null
}
return obj as T
}
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.utils.ext
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import androidx.annotation.Px
import kotlin.math.roundToInt
@@ -9,3 +11,13 @@ fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt()
@Px
fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density
@SuppressLint("DiscouragedApi")
fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean {
val id = Resources.getSystem().getIdentifier(resName, "bool", "android")
return if (id != 0) {
createPackageContext("android", 0).resources.getBoolean(id)
} else {
fallback
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.image
import android.content.Context
import android.graphics.drawable.Drawable
import android.text.Html
import androidx.annotation.WorkerThread
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
@@ -14,6 +15,7 @@ class CoilImageGetter @Inject constructor(
private val coil: ImageLoader,
) : Html.ImageGetter {
@WorkerThread
override fun getDrawable(source: String?): Drawable? {
return coil.executeBlocking(
ImageRequest.Builder(context)

View File

@@ -6,14 +6,22 @@ import coil.request.SuccessResult
import com.google.android.material.progressindicator.BaseProgressIndicator
class ImageRequestIndicatorListener(
private val indicator: BaseProgressIndicator<*>,
private val indicators: Collection<BaseProgressIndicator<*>>,
) : ImageRequest.Listener {
override fun onCancel(request: ImageRequest) = indicator.hide()
override fun onCancel(request: ImageRequest) = hide()
override fun onError(request: ImageRequest, result: ErrorResult) = indicator.hide()
override fun onError(request: ImageRequest, result: ErrorResult) = hide()
override fun onStart(request: ImageRequest) = indicator.show()
override fun onStart(request: ImageRequest) = show()
override fun onSuccess(request: ImageRequest, result: SuccessResult) = indicator.hide()
}
override fun onSuccess(request: ImageRequest, result: SuccessResult) = hide()
private fun hide() {
indicators.forEach { it.hide() }
}
private fun show() {
indicators.forEach { it.show() }
}
}

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.utils.progress
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Deprecated("Should be replaced with Float")
@Parcelize
data class Progress(
val value: Int,
val total: Int,
) : Parcelable, Comparable<Progress> {
override fun compareTo(other: Progress): Int {
return if (this.total == other.total) {
this.value.compareTo(other.value)
} else {
this.part().compareTo(other.part())
}
}
val isIndeterminate: Boolean
get() = total <= 0
private fun part() = if (isIndeterminate) -1.0 else value / total.toDouble()
}

View File

@@ -7,9 +7,9 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
class RecentWidgetProvider : AppWidgetProvider() {
@@ -27,16 +27,18 @@ class RecentWidgetProvider : AppWidgetProvider() {
val intent = Intent(context, ReaderActivity::class.java)
intent.action = ReaderActivity.ACTION_MANGA_READ
views.setPendingIntentTemplate(
R.id.stackView, PendingIntent.getActivity(
R.id.stackView,
PendingIntentCompat.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
PendingIntent.FLAG_UPDATE_CURRENT,
true,
),
)
views.setEmptyView(R.id.stackView, R.id.textView_holder)
appWidgetManager.updateAppWidget(id, views)
}
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.stackView)
}
}
}

View File

@@ -7,9 +7,9 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
class ShelfWidgetProvider : AppWidgetProvider() {
@@ -27,16 +27,18 @@ class ShelfWidgetProvider : AppWidgetProvider() {
val intent = Intent(context, ReaderActivity::class.java)
intent.action = ReaderActivity.ACTION_MANGA_READ
views.setPendingIntentTemplate(
R.id.gridView, PendingIntent.getActivity(
R.id.gridView,
PendingIntentCompat.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
PendingIntent.FLAG_UPDATE_CURRENT,
true,
),
)
views.setEmptyView(R.id.gridView, R.id.textView_holder)
appWidgetManager.updateAppWidget(id, views)
}
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.gridView)
}
}
}

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