Code cleanup and refactor

This commit is contained in:
Koitharu
2024-05-10 08:57:45 +03:00
parent 77ad21bd7a
commit 82684601b7
36 changed files with 161 additions and 414 deletions

View File

@@ -12,9 +12,6 @@ import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao @Dao
abstract class BookmarksDao { abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId") @Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun find(pageId: Long): BookmarkEntity? abstract suspend fun find(pageId: Long): BookmarkEntity?
@@ -42,9 +39,6 @@ abstract class BookmarksDao {
@Delete @Delete
abstract suspend fun delete(entity: BookmarkEntity) abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE page_id = :pageId") @Query("DELETE FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun delete(pageId: Long): Int abstract suspend fun delete(pageId: Long): Int

View File

@@ -26,9 +26,6 @@ import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
@@ -159,18 +156,6 @@ interface AppModule {
acraScreenLogger, acraScreenLogger,
) )
@Provides
@Singleton
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)
}
}
@Provides @Provides
@Singleton @Singleton
@LocalStorageChanges @LocalStorageChanges

View File

@@ -1,29 +0,0 @@
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
interface ContentCache {
val isCachingEnabled: Boolean
suspend fun getDetails(source: MangaSource, url: String): Manga?
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
fun clear(source: MangaSource)
data class Key(
val source: MangaSource,
val url: String,
)
}

View File

@@ -2,18 +2,19 @@ package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache import androidx.collection.LruCache
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
class ExpiringLruCache<T>( class ExpiringLruCache<T>(
val maxSize: Int, val maxSize: Int,
private val lifetime: Long, private val lifetime: Long,
private val timeUnit: TimeUnit, private val timeUnit: TimeUnit,
) : Iterable<ContentCache.Key> { ) : Iterable<CacheKey> {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize) private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator() override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
operator fun get(key: ContentCache.Key): T? { operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null val value = cache[key] ?: return null
if (value.isExpired) { if (value.isExpired) {
cache.remove(key) cache.remove(key)
@@ -21,7 +22,7 @@ class ExpiringLruCache<T>(
return value.get() return value.get()
} }
operator fun set(key: ContentCache.Key, value: T) { operator fun set(key: CacheKey, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit)) cache.put(key, ExpiringValue(value, lifetime, timeUnit))
} }
@@ -33,7 +34,7 @@ class ExpiringLruCache<T>(
cache.trimToSize(size) cache.trimToSize(size)
} }
fun remove(key: ContentCache.Key) { fun remove(key: CacheKey) {
cache.remove(key) cache.remove(key)
} }
} }

View File

@@ -3,48 +3,54 @@ package org.koitharu.kotatsu.core.cache
import android.app.Application import android.app.Application
import android.content.ComponentCallbacks2 import android.content.ComponentCallbacks2
import android.content.res.Configuration import android.content.res.Configuration
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { @Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
private val isLowRam = application.isLowRamDevice()
init { init {
application.registerComponentCallbacks(this) application.registerComponentCallbacks(this)
} }
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES) private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES) private val pagesCache =
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES) ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull()
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
} }
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) { fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[ContentCache.Key(source, url)] = details detailsCache[Key(source, url)] = details
} }
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? { suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull() return pagesCache[Key(source, url)]?.awaitOrNull()
} }
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) { fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[ContentCache.Key(source, url)] = pages pagesCache[Key(source, url)] = pages
} }
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? { suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull() return relatedMangaCache[Key(source, url)]?.awaitOrNull()
} }
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) { fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[ContentCache.Key(source, url)] = related relatedMangaCache[Key(source, url)] = related
} }
override fun clear(source: MangaSource) { fun clear(source: MangaSource) {
clearCache(detailsCache, source) clearCache(detailsCache, source)
clearCache(pagesCache, source) clearCache(pagesCache, source)
clearCache(relatedMangaCache, source) clearCache(relatedMangaCache, source)
@@ -81,4 +87,9 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
} }
} }
} }
data class Key(
val source: MangaSource,
val url: String,
)
} }

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class StubContentCache : ContentCache {
override val isCachingEnabled: Boolean = false
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
override fun clear(source: MangaSource) = Unit
}

View File

@@ -40,7 +40,7 @@ abstract class MangaDao {
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags> abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Upsert @Upsert
abstract suspend fun upsert(manga: MangaEntity) protected abstract suspend fun upsert(manga: MangaEntity)
@Update(onConflict = OnConflictStrategy.IGNORE) @Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(manga: MangaEntity): Int abstract suspend fun update(manga: MangaEntity): Int

View File

@@ -23,9 +23,6 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key") @Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
abstract suspend fun findAllDisabled(): List<MangaSourceEntity> abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0")
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>> abstract fun observeAll(): Flow<List<MangaSourceEntity>>

View File

@@ -28,9 +28,6 @@ interface TrackLogsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)") @Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc() suspend fun gc()

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -57,7 +57,7 @@ interface MangaRepository {
class Factory @Inject constructor( class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache, private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {

View File

@@ -13,10 +13,11 @@ import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
@@ -37,10 +38,14 @@ import java.util.Locale
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: ContentCache, private val cache: MemoryContentCache, // TODO fix concurrency
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor { ) : MangaRepository, Interceptor {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource override val source: MangaSource
get() = parser.source get() = parser.source
@@ -96,7 +101,7 @@ class RemoteMangaRepository(
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe { val pages = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching { mirrorSwitchInterceptor.withMirrorSwitching {
@@ -104,8 +109,8 @@ class RemoteMangaRepository(
} }
} }
cache.putPages(source, chapter.url, pages) cache.putPages(source, chapter.url, pages)
return pages.await() pages
} }.await()
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page) parser.getPageUrl(page)
@@ -123,16 +128,16 @@ class RemoteMangaRepository(
parser.getFavicons() parser.getFavicons()
} }
override suspend fun getRelated(seed: Manga): List<Manga> { override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it } cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe { val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id } parser.getRelatedManga(seed).filterNot { it.id == seed.id }
} }
cache.putRelatedManga(source, seed.url, related) cache.putRelatedManga(source, seed.url, related)
return related.await() related
} }.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga { suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) { if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it } cache.getDetails(source, manga.url)?.let { return it }
} }
@@ -144,8 +149,8 @@ class RemoteMangaRepository(
if (cachePolicy.writeEnabled) { if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details) cache.putDetails(source, manga.url, details)
} }
return details.await() details
} }.await()
suspend fun peekDetails(manga: Manga): Manga? { suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url) return cache.getDetails(source, manga.url)

View File

@@ -3,33 +3,27 @@ package org.koitharu.kotatsu.core.ui
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis") @Suppress("LeakingThis")
@@ -127,32 +121,13 @@ abstract class BaseActivity<B : ViewBinding> :
@CallSuper @CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode) super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode) actionModeDelegate.onSupportActionModeStarted(mode, window)
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(this, R.color.kotatsu_background)
}
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
} }
@CallSuper @CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) { override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode) super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode) actionModeDelegate.onSupportActionModeFinished(mode, window)
window.statusBarColor = defaultStatusBarColor
} }
protected open fun dispatchNavigateUp() { protected open fun dispatchNavigateUp() {
@@ -185,6 +160,12 @@ abstract class BaseActivity<B : ViewBinding> :
} }
} }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}
companion object { companion object {
const val EXTRA_DATA = "data" const val EXTRA_DATA = "data"

View File

@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -16,15 +14,8 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -33,14 +24,12 @@ import com.google.android.material.sidesheet.SideSheetDialog
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() { abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private var waitingForDismissAllowingStateLoss = false private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false private var isFitToContentsDisabled = false
private var defaultStatusBarColor = Color.TRANSPARENT
var viewBinding: B? = null var viewBinding: B? = null
private set private set
@@ -105,40 +94,18 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
@CallSuper @CallSuper
protected open fun dispatchSupportActionModeStarted(mode: ActionMode) { protected open fun dispatchSupportActionModeStarted(mode: ActionMode) {
actionModeDelegate?.onSupportActionModeStarted(mode) actionModeDelegate?.onSupportActionModeStarted(mode, dialog?.window)
val ctx = requireContext()
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, com.google.android.material.R.color.m3_appbar_overlay_color),
ctx.getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
dialog?.window?.let {
defaultStatusBarColor = it.statusBarColor
it.statusBarColor = actionModeColor
}
val insets = ViewCompat.getRootWindowInsets(requireView())
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
dialog?.window?.decorView?.findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)?.apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
} }
@CallSuper @CallSuper
protected open fun dispatchSupportActionModeFinished(mode: ActionMode) { protected open fun dispatchSupportActionModeFinished(mode: ActionMode) {
actionModeDelegate?.onSupportActionModeFinished(mode) actionModeDelegate?.onSupportActionModeFinished(mode, dialog?.window)
dialog?.window?.statusBarColor = defaultStatusBarColor
} }
fun addSheetCallback(callback: AdaptiveSheetCallback, lifecycleOwner: LifecycleOwner): Boolean { fun addSheetCallback(callback: AdaptiveSheetCallback, lifecycleOwner: LifecycleOwner): Boolean {
val b = behavior ?: return false val b = behavior ?: return false
b.addCallback(callback) b.addCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet) val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet)
?: dialog?.findViewById(materialR.id.coordinator) ?: dialog?.findViewById(materialR.id.coordinator)
?: view ?: view
if (rootView != null) { if (rootView != null) {

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.core.ui.util package org.koitharu.kotatsu.core.ui.sheet
import android.view.View import android.view.View
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
@@ -6,9 +6,8 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class BottomSheetClollapseCallback( class BottomSheetCollapseCallback(
private val behavior: BottomSheetBehavior<*>, private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) { ) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {

View File

@@ -1,14 +1,28 @@
package org.koitharu.kotatsu.core.ui.util package org.koitharu.kotatsu.core.ui.util
import android.graphics.Color
import android.os.Build
import android.view.ViewGroup
import android.view.Window
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
class ActionModeDelegate : OnBackPressedCallback(false) { class ActionModeDelegate : OnBackPressedCallback(false) {
private var activeActionMode: ActionMode? = null private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null private var listeners: MutableList<ActionModeListener>? = null
private var defaultStatusBarColor = Color.TRANSPARENT
val isActionModeStarted: Boolean val isActionModeStarted: Boolean
get() = activeActionMode != null get() = activeActionMode != null
@@ -17,16 +31,40 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
finishActionMode() finishActionMode()
} }
fun onSupportActionModeStarted(mode: ActionMode) { fun onSupportActionModeStarted(mode: ActionMode, window: Window?) {
activeActionMode = mode activeActionMode = mode
isEnabled = true isEnabled = true
listeners?.forEach { it.onActionModeStarted(mode) } listeners?.forEach { it.onActionModeStarted(mode) }
if (window != null) {
val ctx = window.context
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
ctx.getThemeColor(materialR.attr.colorSurface),
)
} else {
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
}
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
val insets = ViewCompat.getRootWindowInsets(window.decorView)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
window.decorView.findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)?.apply {
setBackgroundColor(actionModeColor)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
} }
fun onSupportActionModeFinished(mode: ActionMode) { fun onSupportActionModeFinished(mode: ActionMode, window: Window?) {
activeActionMode = null activeActionMode = null
isEnabled = false isEnabled = false
listeners?.forEach { it.onActionModeFinished(mode) } listeners?.forEach { it.onActionModeFinished(mode) }
if (window != null) {
window.statusBarColor = defaultStatusBarColor
}
} }
fun addListener(listener: ActionModeListener) { fun addListener(listener: ActionModeListener) {

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
class BottomSheetNoHalfExpandedCallback() : BottomSheetBehavior.BottomSheetCallback() {
private var previousStableState = BottomSheetBehavior.STATE_COLLAPSED
override fun onStateChanged(sheet: View, state: Int) {
if (state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
val behavior = (sheet.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<*>
behavior?.state = previousStableState
} else if (state == BottomSheetBehavior.STATE_EXPANDED || state == BottomSheetBehavior.STATE_COLLAPSED) {
previousStableState = state
}
}
override fun onSlide(sheet: View, offset: Float) = Unit
}

View File

@@ -1,42 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import android.animation.ValueAnimator
import android.view.animation.AccelerateDecelerateInterpolator
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.shape.MaterialShapeDrawable
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import com.google.android.material.R as materialR
class StatusBarDimHelper : AppBarLayout.OnOffsetChangedListener {
private var animator: ValueAnimator? = null
private val interpolator = AccelerateDecelerateInterpolator()
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
val foreground = appBarLayout.statusBarForeground ?: return
val start = foreground.alpha
val collapsed = verticalOffset != 0
val end = if (collapsed) 255 else 0
animator?.cancel()
if (start == end) {
animator = null
return
}
animator = ValueAnimator.ofInt(start, end).apply {
duration = appBarLayout.context.getAnimationDuration(materialR.integer.app_bar_elevation_anim_duration)
interpolator = this@StatusBarDimHelper.interpolator
addUpdateListener {
foreground.alpha = it.animatedValue as Int
}
start()
}
}
fun attachToAppBar(appBarLayout: AppBarLayout) {
appBarLayout.addOnOffsetChangedListener(this)
appBarLayout.statusBarForeground =
MaterialShapeDrawable.createWithElevationOverlay(appBarLayout.context).apply {
alpha = 0
}
}
}

View File

@@ -1,36 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@SuppressLint("ClickableViewAccessibility")
class EnhancedViewPager @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : ViewPager(context, attrs) {
var isUserInputEnabled: Boolean = true
set(value) {
field = value
if (!value) {
cancelPendingInputEvents()
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onTouchEvent(event)
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return try {
isUserInputEnabled && super.onInterceptTouchEvent(event)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> { class MultiMutex<T : Any> : Set<T> {
@@ -10,12 +12,12 @@ class MultiMutex<T : Any> : Set<T> {
override val size: Int override val size: Int
get() = delegates.size get() = delegates.size
override fun contains(element: T): Boolean { override fun contains(element: T): Boolean = synchronized(delegates) {
return delegates.containsKey(element) delegates.containsKey(element)
} }
override fun containsAll(elements: Collection<T>): Boolean { override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
return elements.all { x -> delegates.containsKey(x) } elements.all { x -> delegates.containsKey(x) }
} }
override fun isEmpty(): Boolean { override fun isEmpty(): Boolean {
@@ -40,4 +42,16 @@ class MultiMutex<T : Any> : Set<T> {
delegates.remove(element)?.unlock() delegates.remove(element)?.unlock()
} }
} }
suspend inline fun <R> withLock(element: T, block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return try {
lock(element)
block()
} finally {
unlock(element)
}
}
} }

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
@@ -27,7 +27,7 @@ class MangaPrefetchService : CoroutineIntentService() {
lateinit var mangaRepositoryFactory: MangaRepository.Factory lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject @Inject
lateinit var cache: ContentCache lateinit var cache: MemoryContentCache
@Inject @Inject
lateinit var historyRepository: HistoryRepository lateinit var historyRepository: HistoryRepository
@@ -120,7 +120,7 @@ class MangaPrefetchService : CoroutineIntentService() {
context, context,
PrefetchCompanionEntryPoint::class.java, PrefetchCompanionEntryPoint::class.java,
) )
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.settings.isContentPrefetchEnabled
} }
private fun tryStart(context: Context, intent: Intent) { private fun tryStart(context: Context, intent: Intent) {

View File

@@ -3,12 +3,10 @@ package org.koitharu.kotatsu.details.service
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface PrefetchCompanionEntryPoint { interface PrefetchCompanionEntryPoint {
val settings: AppSettings val settings: AppSettings
val contentCache: ContentCache
} }

View File

@@ -53,7 +53,7 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.BottomSheetClollapseCallback import org.koitharu.kotatsu.core.ui.sheet.BottomSheetCollapseCallback
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
@@ -154,7 +154,7 @@ class DetailsActivity :
viewBinding.chipsTags.onChipClickListener = this viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior -> viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior ->
onBackPressedDispatcher.addCallback(BottomSheetClollapseCallback(behavior)) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(behavior))
} }
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)

View File

@@ -205,8 +205,8 @@ class ExploreFragment :
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source)) startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source))
} }
R.id.action_hide -> { R.id.action_disable -> {
viewModel.hideSource(sourceItem.source) viewModel.disableSource(sourceItem.source)
} }
R.id.action_shortcut -> { R.id.action_shortcut -> {

View File

@@ -92,7 +92,7 @@ class ExploreViewModel @Inject constructor(
} }
} }
fun hideSource(source: MangaSource) { fun disableSource(source: MangaSource) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false) val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false)
onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) onActionDone.call(ReversibleAction(R.string.source_disabled, rollback))

View File

@@ -20,7 +20,7 @@ abstract class FavouriteCategoriesDao {
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>> abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key") @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key")
abstract fun observeAllForLibrary(): Flow<List<FavouriteCategoryEntity>> abstract fun observeAllVisible(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0") @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?> abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
@@ -40,7 +40,7 @@ abstract class FavouriteCategoriesDao {
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean) abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id") @Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean) abstract suspend fun updateVisibility(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int) abstract suspend fun updateSortKey(id: Long, sortKey: Int)

View File

@@ -11,7 +11,6 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -39,13 +38,6 @@ abstract class FavouritesDao {
return observeAllImpl(query) return observeAllImpl(query)
} }
@Transaction
@Query(
"SELECT * FROM favourites WHERE deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction @Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@@ -72,19 +64,6 @@ abstract class FavouritesDao {
return observeAllImpl(query) return observeAllImpl(query)
} }
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query(
"SELECT * FROM manga WHERE manga_id IN " +
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)",
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> { suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@@ -114,21 +93,9 @@ abstract class FavouritesDao {
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0") @Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int> abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
@Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0") @Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
abstract suspend fun findAllRaw(mangaId: Long): List<FavouriteEntity> abstract suspend fun findAllRaw(mangaId: Long): List<FavouriteEntity>
@Transaction
@Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract fun observe(id: Long): Flow<FavouriteManga?>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0") @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow<List<Long>> abstract fun observeIds(id: Long): Flow<List<Long>>
@@ -138,9 +105,6 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC") @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long> abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT DISTINCT favourite_categories.category_id FROM favourites LEFT JOIN favourite_categories ON favourites.category_id = favourite_categories.category_id WHERE manga_id = :mangaId AND favourites.deleted_at = 0 AND favourite_categories.deleted_at = 0 AND favourite_categories.track = 1")
abstract suspend fun findCategoriesIdsWithTrack(mangaId: Long): List<Long>
/** INSERT **/ /** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -194,7 +158,7 @@ abstract class FavouritesDao {
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId") @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId")
abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long) protected abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long)
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0") @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0")
protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long) protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long)

View File

@@ -76,7 +76,7 @@ class FavouritesRepository @Inject constructor(
} }
fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> { fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems { return db.getFavouriteCategoriesDao().observeAllVisible().mapItems {
it.toFavouriteCategory() it.toFavouriteCategory()
}.distinctUntilChanged() }.distinctUntilChanged()
} }
@@ -157,7 +157,7 @@ class FavouritesRepository @Inject constructor(
} }
suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) { suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
db.getFavouriteCategoriesDao().updateLibVisibility(id, isVisibleInLibrary) db.getFavouriteCategoriesDao().updateVisibility(id, isVisibleInLibrary)
} }
suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) { suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) {

View File

@@ -10,7 +10,6 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -21,10 +20,6 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga> abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity>
@Transaction @Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
abstract fun observeAll(): Flow<List<HistoryWithManga>> abstract fun observeAll(): Flow<List<HistoryWithManga>>
@@ -33,6 +28,7 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>> abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
// TODO pagination
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> { fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
val orderBy = when (order) { val orderBy = when (order) {
ListSortOrder.LAST_READ -> "history.updated_at DESC" ListSortOrder.LAST_READ -> "history.updated_at DESC"
@@ -56,9 +52,6 @@ abstract class HistoryDao {
return observeAllImpl(query) return observeAllImpl(query)
} }
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT manga_id FROM history WHERE deleted_at = 0") @Query("SELECT manga_id FROM history WHERE deleted_at = 0")
abstract suspend fun findAllIds(): LongArray abstract suspend fun findAllIds(): LongArray

View File

@@ -8,7 +8,7 @@ import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
@@ -23,11 +23,10 @@ class NetworkSettingsFragment :
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
@Inject @Inject
lateinit var contentCache: ContentCache lateinit var contentCache: MemoryContentCache
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_network) addPreferencesFromResource(R.xml.pref_network)
findPreference<Preference>(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run { findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
entryValues = DoHProvider.entries.names() entryValues = DoHProvider.entries.names()
setDefaultValueCompat(DoHProvider.NONE.name) setDefaultValueCompat(DoHProvider.NONE.name)

View File

@@ -1,25 +1,18 @@
package org.koitharu.kotatsu.stats.data package org.koitharu.kotatsu.stats.data
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Dao import androidx.room.Dao
import androidx.room.MapColumn import androidx.room.MapColumn
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.HistoryWithManga
@Dao @Dao
abstract class StatsDao { abstract class StatsDao {
@Query("SELECT * FROM stats ORDER BY started_at")
abstract suspend fun findAll(): List<StatsEntity>
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at") @Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
abstract suspend fun findAll(mangaId: Long): List<StatsEntity> abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
@@ -32,12 +25,6 @@ abstract class StatsDao {
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats") @Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats")
abstract suspend fun getAverageTimePerPage(): Long abstract suspend fun getAverageTimePerPage(): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadingTime(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
abstract suspend fun getTotalReadingTime(): Long
@Query("DELETE FROM stats") @Query("DELETE FROM stats")
abstract suspend fun clear() abstract suspend fun clear()
@@ -47,7 +34,11 @@ abstract class StatsDao {
@Upsert @Upsert
abstract suspend fun upsert(entity: StatsEntity) abstract suspend fun upsert(entity: StatsEntity)
suspend fun getDurationStats(fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set<Long>): Map<MangaEntity, Long> { suspend fun getDurationStats(
fromDate: Long,
isNsfw: Boolean?,
favouriteCategories: Set<Long>
): Map<MangaEntity, Long> {
val conditions = ArrayList<String>() val conditions = ArrayList<String>()
conditions.add("stats.started_at >= $fromDate") conditions.add("stats.started_at >= $fromDate")
if (favouriteCategories.isNotEmpty()) { if (favouriteCategories.isNotEmpty()) {
@@ -66,7 +57,7 @@ abstract class StatsDao {
} }
@RawQuery @RawQuery
protected abstract fun getDurationStatsImpl( protected abstract suspend fun getDurationStatsImpl(
query: SupportSQLiteQuery query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long> ): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
} }

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.tracker.data package org.koitharu.kotatsu.tracker.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
@@ -10,9 +9,6 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class TracksDao { abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity>
@Transaction @Transaction
@Query("SELECT * FROM tracks ORDER BY last_check_time ASC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM tracks ORDER BY last_check_time ASC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<TrackWithManga> abstract suspend fun findAll(offset: Int, limit: Int): List<TrackWithManga>
@@ -24,9 +20,6 @@ abstract class TracksDao {
@Query("SELECT manga_id FROM tracks") @Query("SELECT manga_id FROM tracks")
abstract suspend fun findAllIds(): LongArray abstract suspend fun findAllIds(): LongArray
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId") @Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity? abstract suspend fun find(mangaId: Long): TrackEntity?
@@ -36,9 +29,6 @@ abstract class TracksDao {
@Query("SELECT COUNT(*) FROM tracks") @Query("SELECT COUNT(*) FROM tracks")
abstract suspend fun getTracksCount(): Int abstract suspend fun getTracksCount(): Int
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn(columnName = "chapters_new") Int>>
@Query("SELECT chapters_new FROM tracks") @Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>> abstract fun observeNewChapters(): Flow<List<Int>>

View File

@@ -15,8 +15,6 @@ import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
@Reusable @Reusable
class Tracker @Inject constructor( class Tracker @Inject constructor(
@@ -25,6 +23,8 @@ class Tracker @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
private val mangaMutex = MultiMutex<Long>()
suspend fun getTracks(limit: Int): List<MangaTracking> { suspend fun getTracks(limit: Int): List<MangaTracking> {
repository.updateTracks() repository.updateTracks()
return repository.getTracks(offset = 0, limit = limit) return repository.getTracks(offset = 0, limit = limit)
@@ -37,7 +37,7 @@ class Tracker @Inject constructor(
suspend fun fetchUpdates( suspend fun fetchUpdates(
track: MangaTracking, track: MangaTracking,
commit: Boolean commit: Boolean
): MangaUpdates = withMangaLock(track.manga.id) { ): MangaUpdates = mangaMutex.withLock(track.manga.id) {
val updates = runCatchingCancellable { val updates = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(track.manga.source) val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" } require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
@@ -52,7 +52,7 @@ class Tracker @Inject constructor(
if (commit) { if (commit) {
repository.saveUpdates(updates) repository.saveUpdates(updates)
} }
return updates updates
} }
suspend fun syncWithDetails(details: Manga) { suspend fun syncWithDetails(details: Manga) {
@@ -94,7 +94,7 @@ class Tracker @Inject constructor(
} }
@VisibleForTesting @VisibleForTesting
suspend fun deleteTrack(mangaId: Long) = withMangaLock(mangaId) { suspend fun deleteTrack(mangaId: Long) = mangaMutex.withLock(mangaId) {
repository.deleteTrack(mangaId) repository.deleteTrack(mangaId)
} }
@@ -135,18 +135,5 @@ class Tracker @Inject constructor(
private companion object { private companion object {
const val NO_ID = 0L const val NO_ID = 0L
private val mangaMutex = MultiMutex<Long>()
suspend inline fun <T> withMangaLock(id: Long, action: () -> T): T {
contract {
callsInPlace(action, InvocationKind.EXACTLY_ONCE)
}
mangaMutex.lock(id)
try {
return action()
} finally {
mangaMutex.unlock(id)
}
}
} }
} }

View File

@@ -11,7 +11,7 @@
android:title="@string/create_shortcut" /> android:title="@string/create_shortcut" />
<item <item
android:id="@+id/action_hide" android:id="@+id/action_disable"
android:title="@string/hide" /> android:title="@string/disable" />
</menu> </menu>

View File

@@ -641,4 +641,5 @@
<string name="suggested_queries">Suggested queries</string> <string name="suggested_queries">Suggested queries</string>
<string name="authors">Authors</string> <string name="authors">Authors</string>
<string name="blocked_by_server_message">You are blocked by the server. Try to use a different network connection (VPN, Proxy, etc.)</string> <string name="blocked_by_server_message">You are blocked by the server. Try to use a different network connection (VPN, Proxy, etc.)</string>
<string name="disable">Disable</string>
</resources> </resources>

View File

@@ -10,7 +10,6 @@
android:entryValues="@array/values_network_policy" android:entryValues="@array/values_network_policy"
android:key="prefetch_content" android:key="prefetch_content"
android:title="@string/prefetch_content" android:title="@string/prefetch_content"
app:isPreferenceVisible="false"
app:useSimpleSummaryProvider="true" app:useSimpleSummaryProvider="true"
tools:isPreferenceVisible="true" /> tools:isPreferenceVisible="true" />