Local manga index in database
This commit is contained in:
@@ -273,6 +273,7 @@
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||
import java.security.Security
|
||||
import javax.inject.Inject
|
||||
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@Inject
|
||||
@LocalStorageChanges
|
||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
@@ -82,6 +93,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
}
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||
@@ -60,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 22
|
||||
const val DATABASE_VERSION = 23
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
|
||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
Migration22To23(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration22To23 : Migration(22, 23) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
|
||||
@@ -199,6 +199,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
|
||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class FavouritesRepository @Inject constructor(
|
||||
|
||||
fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit)
|
||||
return localObserver.observeAll(order, filterOptions, limit)
|
||||
}
|
||||
return db.getFavouritesDao().observeAll(order, filterOptions, limit)
|
||||
.mapItems { it.toManga() }
|
||||
@@ -60,7 +60,7 @@ class FavouritesRepository @Inject constructor(
|
||||
limit: Int
|
||||
): Flow<List<Manga>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(categoryId, order, filterOptions - ListFilterOption.Downloaded, limit)
|
||||
return localObserver.observeAll(categoryId, order, filterOptions, limit)
|
||||
}
|
||||
return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit)
|
||||
.mapItems { it.toManga() }
|
||||
|
||||
@@ -2,29 +2,30 @@ package org.koitharu.kotatsu.favourites.domain
|
||||
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.LocalObserveMapper
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class LocalFavoritesObserver @Inject constructor(
|
||||
localMangaRepository: LocalMangaRepository,
|
||||
localMangaIndex: LocalMangaIndex,
|
||||
private val db: MangaDatabase,
|
||||
) : LocalObserveMapper<FavouriteManga, Manga>(localMangaRepository, limitStep = 10) {
|
||||
) : LocalObserveMapper<FavouriteManga, Manga>(localMangaIndex, limitStep = 10) {
|
||||
|
||||
fun observeAll(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<Manga>> = observe(limit) { newLimit ->
|
||||
db.getFavouritesDao().observeAll(order, filterOptions, newLimit)
|
||||
): Flow<List<Manga>> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapLatest {
|
||||
it.mapToLocal()
|
||||
}
|
||||
|
||||
fun observeAll(
|
||||
@@ -32,8 +33,8 @@ class LocalFavoritesObserver @Inject constructor(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
): Flow<List<Manga>> = observe(limit) { newLimit ->
|
||||
db.getFavouritesDao().observeAll(categoryId, order, filterOptions, newLimit)
|
||||
): Flow<List<Manga>> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapLatest {
|
||||
it.mapToLocal()
|
||||
}
|
||||
|
||||
override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags())
|
||||
|
||||
@@ -159,6 +159,7 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
||||
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
|
||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
package org.koitharu.kotatsu.history.data
|
||||
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.LocalObserveMapper
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class HistoryLocalObserver @Inject constructor(
|
||||
localMangaRepository: LocalMangaRepository,
|
||||
localMangaIndex: LocalMangaIndex,
|
||||
private val db: MangaDatabase,
|
||||
) : LocalObserveMapper<HistoryWithManga, MangaWithHistory>(localMangaRepository, limitStep = 10) {
|
||||
) : LocalObserveMapper<HistoryWithManga, MangaWithHistory>(localMangaIndex, limitStep = 10) {
|
||||
|
||||
fun observeAll(
|
||||
order: ListSortOrder,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
limit: Int
|
||||
) = observe(limit) { newLimit ->
|
||||
db.getHistoryDao().observeAll(order, filterOptions, newLimit)
|
||||
) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapLatest {
|
||||
it.mapToLocal()
|
||||
}
|
||||
|
||||
override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags())
|
||||
|
||||
@@ -82,7 +82,7 @@ class HistoryRepository @Inject constructor(
|
||||
limit: Int
|
||||
): Flow<List<MangaWithHistory>> {
|
||||
if (ListFilterOption.Downloaded in filterOptions) {
|
||||
return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit)
|
||||
return localObserver.observeAll(order, filterOptions, limit)
|
||||
}
|
||||
return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems {
|
||||
MangaWithHistory(
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.collection.MutableLongObjectMap
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
|
||||
class LocalMangaMappingCache {
|
||||
|
||||
private val map = MutableLongObjectMap<File>()
|
||||
|
||||
suspend fun get(mangaId: Long): LocalManga? {
|
||||
val file = synchronized(this) {
|
||||
map[mangaId]
|
||||
} ?: return null
|
||||
return runCatchingCancellable {
|
||||
LocalMangaInput.of(file).getManga()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
operator fun set(mangaId: Long, localManga: LocalManga?) = synchronized(this) {
|
||||
if (localManga == null) {
|
||||
map.remove(mangaId)
|
||||
} else {
|
||||
map[mangaId] = localManga.file
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.local.data
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
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.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.toCollection
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
@@ -20,6 +19,7 @@ import org.koitharu.kotatsu.core.util.ext.children
|
||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.filterWith
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
|
||||
@@ -43,13 +43,13 @@ private const val MAX_PARALLELISM = 4
|
||||
@Singleton
|
||||
class LocalMangaRepository @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val localMangaIndex: LocalMangaIndex,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
private val settings: AppSettings,
|
||||
private val lock: MangaLock,
|
||||
) : MangaRepository {
|
||||
|
||||
override val source = LocalMangaSource
|
||||
private val localMappingCache = LocalMangaMappingCache()
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities(
|
||||
@@ -116,6 +116,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
val file = Uri.parse(manga.url).toFile()
|
||||
val result = file.deleteAwait()
|
||||
if (result) {
|
||||
localMangaIndex.delete(manga.id)
|
||||
localStorageChanges.emit(null)
|
||||
}
|
||||
return result
|
||||
@@ -139,7 +140,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
|
||||
// very fast path
|
||||
localMappingCache.get(remoteManga.id)?.let {
|
||||
localMangaIndex.get(remoteManga.id)?.let {
|
||||
return@runCatchingCancellable it
|
||||
}
|
||||
// fast path
|
||||
@@ -164,7 +165,9 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
}.firstOrNull()?.getManga()
|
||||
}.onSuccess { x: LocalManga? ->
|
||||
localMappingCache[remoteManga.id] = x
|
||||
if (x != null) {
|
||||
localMangaIndex.put(x)
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
@@ -199,18 +202,21 @@ class LocalMangaRepository @Inject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun getRawList(): ArrayList<LocalManga> {
|
||||
val files = getAllFiles().toList() // TODO remove toList()
|
||||
return coroutineScope {
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||
files.map { file ->
|
||||
async(dispatcher) {
|
||||
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
|
||||
fun getRawListAsFlow(): Flow<LocalManga> = channelFlow {
|
||||
val files = getAllFiles()
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
|
||||
for (file in files) {
|
||||
launch(dispatcher) {
|
||||
val m = LocalMangaInput.ofOrNull(file)?.getManga()
|
||||
if (m != null) {
|
||||
send(m)
|
||||
}
|
||||
}.awaitAll()
|
||||
}.filterNotNullTo(ArrayList(files.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getRawList(): ArrayList<LocalManga> = getRawListAsFlow().toCollection(ArrayList())
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||
.asSequence()
|
||||
.flatMap { dir ->
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.koitharu.kotatsu.local.data.index
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.room.withTransaction
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LocalMangaIndex @Inject constructor(
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val db: MangaDatabase,
|
||||
private val localStorageManager: LocalStorageManager,
|
||||
@ApplicationContext context: Context,
|
||||
private val localMangaRepositoryProvider: Provider<LocalMangaRepository>,
|
||||
) : FlowCollector<LocalManga?> {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private var previousHash: Long
|
||||
get() = prefs.getLong(KEY_HASH, 0L)
|
||||
set(value) = prefs.edit { putLong(KEY_HASH, value) }
|
||||
|
||||
override suspend fun emit(value: LocalManga?) {
|
||||
if (value != null) {
|
||||
put(value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun update(): Boolean {
|
||||
val newHash = computeHash()
|
||||
if (newHash == previousHash) {
|
||||
return false
|
||||
}
|
||||
db.withTransaction {
|
||||
val dao = db.getLocalMangaIndexDao()
|
||||
dao.clear()
|
||||
localMangaRepositoryProvider.get().getRawListAsFlow()
|
||||
.collect { dao.upsert(it.toEntity()) }
|
||||
}
|
||||
previousHash = newHash
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun get(mangaId: Long): LocalManga? {
|
||||
val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null
|
||||
return runCatchingCancellable {
|
||||
LocalMangaInput.of(File(path)).getManga()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun put(manga: LocalManga) = db.withTransaction {
|
||||
mangaDataRepository.storeManga(manga.manga)
|
||||
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
||||
}
|
||||
|
||||
suspend fun delete(mangaId: Long) {
|
||||
db.getLocalMangaIndexDao().delete(mangaId)
|
||||
}
|
||||
|
||||
private fun LocalManga.toEntity() = LocalMangaIndexEntity(
|
||||
mangaId = manga.id,
|
||||
path = file.path,
|
||||
)
|
||||
|
||||
private suspend fun computeHash(): Long {
|
||||
return runCatchingCancellable {
|
||||
localStorageManager.getReadableDirs()
|
||||
.fold(0L) { acc, file -> acc + file.computeHash() }
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrDefault(0L)
|
||||
}
|
||||
|
||||
private suspend fun File.computeHash(): Long = runInterruptible(Dispatchers.IO) {
|
||||
lastModified() // TODO size
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PREF_NAME = "_local_index"
|
||||
private const val KEY_HASH = "hash"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.local.data.index
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
@Dao
|
||||
interface LocalMangaIndexDao {
|
||||
|
||||
@Query("SELECT path FROM local_index WHERE manga_id = :mangaId")
|
||||
suspend fun findPath(mangaId: Long): String?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(entity: LocalMangaIndexEntity)
|
||||
|
||||
@Query("DELETE FROM local_index WHERE manga_id = :mangaId")
|
||||
suspend fun delete(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM local_index")
|
||||
suspend fun clear()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.koitharu.kotatsu.local.data.index
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
|
||||
@Entity(
|
||||
tableName = "local_index",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
class LocalMangaIndexEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "path") val path: String,
|
||||
)
|
||||
@@ -1,56 +1,29 @@
|
||||
package org.koitharu.kotatsu.local.domain
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
abstract class LocalObserveMapper<E : Any, R : Any>(
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val localMangaIndex: LocalMangaIndex,
|
||||
private val limitStep: Int,
|
||||
) {
|
||||
|
||||
private val cache = Collections.synchronizedMap(WeakHashMap<Manga, R?>())
|
||||
|
||||
protected fun observe(limit: Int, observer: (limit: Int) -> Flow<List<E>>): Flow<List<R>> {
|
||||
val floatingLimit = MutableStateFlow(limit)
|
||||
return floatingLimit.flatMapLatest { l ->
|
||||
observer(l)
|
||||
.transformLatest { fullList ->
|
||||
val mapped = fullList.mapToLocal(cache)
|
||||
if (mapped.size < limit && fullList.size == l) {
|
||||
floatingLimit.value += limitStep
|
||||
} else {
|
||||
emit(mapped.take(limit))
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun List<E>.mapToLocal(cache: MutableMap<Manga, R?>): List<R> = coroutineScope {
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(8)
|
||||
protected suspend fun List<E>.mapToLocal(): List<R> = coroutineScope {
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(6)
|
||||
map { item ->
|
||||
val m = toManga(item)
|
||||
if (cache.contains(m)) {
|
||||
CompletableDeferred(cache[m])
|
||||
} else async(dispatcher) {
|
||||
async(dispatcher) {
|
||||
val mapped = if (m.isLocal) {
|
||||
m
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(m)?.manga
|
||||
localMangaIndex.get(m.id)?.manga
|
||||
}
|
||||
mapped?.let { mm -> toResult(item, mm) }.also { cache[m] = it }
|
||||
mapped?.let { mm -> toResult(item, mm) }
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class LocalIndexUpdateService : CoroutineIntentService() {
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndex: LocalMangaIndex
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
localMangaIndex.update()
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) = Unit
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
|
||||
import org.koitharu.kotatsu.history.ui.HistoryListFragment
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
@@ -351,6 +352,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
MangaPrefetchService.prefetchLast(this@MainActivity)
|
||||
requestNotificationsPermission()
|
||||
}
|
||||
startService(Intent(this@MainActivity, LocalMangaIndex::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user