diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31d3ffc16..b315e91c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -273,6 +273,7 @@ + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt index 4a31e64b7..fb37b2cb6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt @@ -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 + @Inject + lateinit var localMangaIndexProvider: Provider + + @Inject + @LocalStorageChanges + lateinit var localStorageChanges: MutableSharedFlow + 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() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 1bec0c9b8..60f1abb02 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -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 = arrayOf( @@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration19To20(), Migration20To21(), Migration21To22(), + Migration22To23(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt new file mode 100644 index 000000000..3ead2c4ff --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration22To23.kt @@ -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 )") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index c678d25d4..c62b7f807 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index b8e444a2d..fe62f0334 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -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 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 85f654492..1e0d91693 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -42,7 +42,7 @@ class FavouritesRepository @Inject constructor( fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { 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> { 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() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt index 0bee8280c..6fcacd4d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt @@ -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(localMangaRepository, limitStep = 10) { +) : LocalObserveMapper(localMangaIndex, limitStep = 10) { fun observeAll( order: ListSortOrder, filterOptions: Set, limit: Int - ): Flow> = observe(limit) { newLimit -> - db.getFavouritesDao().observeAll(order, filterOptions, newLimit) + ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapLatest { + it.mapToLocal() } fun observeAll( @@ -32,8 +33,8 @@ class LocalFavoritesObserver @Inject constructor( order: ListSortOrder, filterOptions: Set, limit: Int - ): Flow> = observe(limit) { newLimit -> - db.getFavouritesDao().observeAll(categoryId, order, filterOptions, newLimit) + ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapLatest { + it.mapToLocal() } override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index 1389e6524..23e0db88e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -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 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt index e1324a2a3..00a95dc28 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt @@ -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(localMangaRepository, limitStep = 10) { +) : LocalObserveMapper(localMangaIndex, limitStep = 10) { fun observeAll( order: ListSortOrder, filterOptions: Set, 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()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 03ccd91e5..6e323bd32 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -82,7 +82,7 @@ class HistoryRepository @Inject constructor( limit: Int ): Flow> { 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( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt deleted file mode 100644 index 7ef175a0d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaMappingCache.kt +++ /dev/null @@ -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() - - 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 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 54f8f0030..d43bf2805 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -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, 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 { - 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 = 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 = getRawListAsFlow().toCollection(ArrayList()) + private suspend fun getAllFiles() = storageManager.getReadableDirs() .asSequence() .flatMap { dir -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt new file mode 100644 index 000000000..9a9c08f43 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -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, +) : FlowCollector { + + 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" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt new file mode 100644 index 000000000..909a0458c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt @@ -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() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt new file mode 100644 index 000000000..32e159c66 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexEntity.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt index 0e94293ea..91525dd26 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt @@ -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( - private val localMangaRepository: LocalMangaRepository, + private val localMangaIndex: LocalMangaIndex, private val limitStep: Int, ) { - private val cache = Collections.synchronizedMap(WeakHashMap()) - - protected fun observe(limit: Int, observer: (limit: Int) -> Flow>): Flow> { - 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.mapToLocal(cache: MutableMap): List = coroutineScope { - val dispatcher = Dispatchers.IO.limitedParallelism(8) + protected suspend fun List.mapToLocal(): List = 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() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt new file mode 100644 index 000000000..72e287b17 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 3360ab460..afd073a9b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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(), AppBarOwner, BottomNav MangaPrefetchService.prefetchLast(this@MainActivity) requestNotificationsPermission() } + startService(Intent(this@MainActivity, LocalMangaIndex::class.java)) } }