mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Restrict raw database access to the data module
app no longer touches the database directly. Backup restore's queries move into a new RestoreRepository in :data, which restores a batch of entries all or nothing, stopping between entries if cancelled, as before. The remaining direct calls (excluded scanlators, clear database, backup creation, store and category restore) go through their repositories, and the database bindings move from AppBindings into :data.
This commit is contained in:
@@ -42,5 +42,6 @@ dependencies {
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
api(libs.bundles.sqldelight)
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
implementation(libs.bundles.sqldelight)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class ExtensionStoreRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upsert(store: ExtensionStore) {
|
||||
override suspend fun upsert(store: ExtensionStore) {
|
||||
database.extension_storeQueries.upsert(
|
||||
indexUrl = store.indexUrl,
|
||||
name = store.name,
|
||||
|
||||
@@ -2,8 +2,9 @@ package tachiyomi.data
|
||||
|
||||
import app.cash.sqldelight.ColumnAdapter
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import mihon.core.common.extensions.toByteArray
|
||||
import mihon.core.common.extensions.toJsonObject
|
||||
import java.util.Date
|
||||
|
||||
object DateColumnAdapter : ColumnAdapter<Date, Long> {
|
||||
@@ -31,11 +32,6 @@ object UpdateStrategyColumnAdapter : ColumnAdapter<UpdateStrategy, Long> {
|
||||
}
|
||||
|
||||
object MemoColumnAdapter : ColumnAdapter<JsonObject, ByteArray> {
|
||||
override fun decode(databaseValue: ByteArray): JsonObject {
|
||||
return Json.decodeFromString<JsonObject>(databaseValue.decodeToString())
|
||||
}
|
||||
|
||||
override fun encode(value: JsonObject): ByteArray {
|
||||
return value.toString().encodeToByteArray()
|
||||
}
|
||||
override fun decode(databaseValue: ByteArray): JsonObject = databaseValue.toJsonObject()
|
||||
override fun encode(value: JsonObject): ByteArray = value.toByteArray()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package tachiyomi.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
|
||||
import com.eygraber.sqldelight.androidx.driver.FileProvider
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
@BindingContainer
|
||||
object DatabaseBindings {
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesSqlDriver(context: Context): SqlDriver {
|
||||
return AndroidxSqliteDriver(
|
||||
driver = BundledSQLiteDriver(),
|
||||
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"),
|
||||
schema = Database.Schema,
|
||||
configuration = AndroidxSqliteConfiguration(
|
||||
isForeignKeyConstraintsEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesDatabase(driver: SqlDriver): Database {
|
||||
return Database(
|
||||
driver = driver,
|
||||
historyAdapter = History.Adapter(
|
||||
last_readAdapter = DateColumnAdapter,
|
||||
),
|
||||
mangasAdapter = Mangas.Adapter(
|
||||
genreAdapter = StringListColumnAdapter,
|
||||
update_strategyAdapter = UpdateStrategyColumnAdapter,
|
||||
memoAdapter = MemoColumnAdapter,
|
||||
),
|
||||
chaptersAdapter = Chapters.Adapter(
|
||||
memoAdapter = MemoColumnAdapter,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package tachiyomi.data.backup
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOne
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.backup.model.RestoredHistory
|
||||
import tachiyomi.domain.backup.model.RestoredManga
|
||||
import tachiyomi.domain.backup.repository.RestoreRepository
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.track.model.Track
|
||||
import tachiyomi.domain.track.repository.TrackRepository
|
||||
import java.util.Date
|
||||
import kotlin.math.max
|
||||
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class RestoreRepositoryImpl(
|
||||
private val database: Database,
|
||||
private val mangaRepository: MangaRepository,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
private val trackRepository: TrackRepository,
|
||||
) : RestoreRepository {
|
||||
|
||||
override suspend fun getMangaUrlsBySourceId(): Map<Long, List<String>> {
|
||||
return database.mangasQueries
|
||||
.getAllMangaSourceAndUrl()
|
||||
.awaitAsList()
|
||||
.groupBy({ it.source }, { it.url })
|
||||
}
|
||||
|
||||
override suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate) {
|
||||
database.transaction {
|
||||
entries.forEach { entry ->
|
||||
// A cancelled restore stops between entries and rolls the whole batch back
|
||||
currentCoroutineContext().ensureActive()
|
||||
mangaRepository.update(update(restore(entry)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restore(entry: RestoredManga): Manga {
|
||||
val dbManga = mangaRepository.getMangaByUrlAndSourceId(entry.manga.url, entry.manga.source)
|
||||
val manga = if (dbManga == null) {
|
||||
entry.manga.copy(id = insertManga(entry.manga))
|
||||
} else {
|
||||
updateManga(mergeManga(entry.manga, dbManga))
|
||||
}
|
||||
|
||||
if (entry.categoryIds.isNotEmpty()) {
|
||||
mangaRepository.setMangaCategories(manga.id, entry.categoryIds)
|
||||
}
|
||||
restoreChapters(manga, entry.chapters)
|
||||
restoreTracking(manga, entry.tracks)
|
||||
restoreHistory(manga, entry.history)
|
||||
restoreExcludedScanlators(manga, entry.excludedScanlators)
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun mergeManga(manga: Manga, dbManga: Manga): Manga {
|
||||
val details = if (dbManga.initialized || !manga.initialized) dbManga else manga
|
||||
return dbManga.copy(
|
||||
favorite = dbManga.favorite || manga.favorite,
|
||||
dateAdded = listOfNotNull(
|
||||
dbManga.dateAdded.takeIf { it > 0 },
|
||||
manga.dateAdded.takeIf { it > 0 },
|
||||
).minOrNull() ?: 0L,
|
||||
title = details.title,
|
||||
artist = details.artist,
|
||||
author = details.author,
|
||||
description = details.description,
|
||||
genre = details.genre,
|
||||
status = details.status,
|
||||
thumbnailUrl = details.thumbnailUrl,
|
||||
updateStrategy = details.updateStrategy,
|
||||
initialized = dbManga.initialized || manga.initialized,
|
||||
// Merge both backup and local data with local winning
|
||||
memo = JsonObject(manga.memo + dbManga.memo),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateManga(manga: Manga): Manga {
|
||||
database.mangasQueries.updateFromBackup(
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre,
|
||||
title = manga.title,
|
||||
status = manga.status,
|
||||
thumbnailUrl = manga.thumbnailUrl,
|
||||
favorite = manga.favorite,
|
||||
lastUpdate = manga.lastUpdate,
|
||||
initialized = manga.initialized,
|
||||
viewer = manga.viewerFlags,
|
||||
chapterFlags = manga.chapterFlags,
|
||||
coverLastModified = manga.coverLastModified,
|
||||
dateAdded = manga.dateAdded,
|
||||
mangaId = manga.id,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
notes = manga.notes,
|
||||
memo = manga.memo,
|
||||
)
|
||||
return manga
|
||||
}
|
||||
|
||||
private suspend fun insertManga(manga: Manga): Long {
|
||||
return database.mangasQueries.insertReturningId(
|
||||
source = manga.source,
|
||||
url = manga.url,
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre,
|
||||
title = manga.title,
|
||||
status = manga.status,
|
||||
thumbnailUrl = manga.thumbnailUrl,
|
||||
favorite = manga.favorite,
|
||||
lastUpdate = manga.lastUpdate,
|
||||
nextUpdate = 0L,
|
||||
calculateInterval = 0L,
|
||||
initialized = manga.initialized,
|
||||
viewerFlags = manga.viewerFlags,
|
||||
chapterFlags = manga.chapterFlags,
|
||||
coverLastModified = manga.coverLastModified,
|
||||
dateAdded = manga.dateAdded,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
notes = manga.notes,
|
||||
memo = manga.memo,
|
||||
)
|
||||
.awaitAsOne()
|
||||
}
|
||||
|
||||
private suspend fun restoreChapters(manga: Manga, restoredChapters: List<Chapter>) {
|
||||
val dbChaptersByUrl = chapterRepository.getChapterByMangaId(manga.id)
|
||||
.associateBy { it.url }
|
||||
|
||||
val (existingChapters, newChapters) = restoredChapters
|
||||
.mapNotNull {
|
||||
val chapter = it.copy(mangaId = manga.id)
|
||||
|
||||
val dbChapter = dbChaptersByUrl[chapter.url]
|
||||
?: // New chapter
|
||||
return@mapNotNull chapter
|
||||
|
||||
if (chapter.forComparison() == dbChapter.forComparison()) {
|
||||
// Same state; skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
chapter
|
||||
.copyFrom(dbChapter)
|
||||
.copy(
|
||||
id = dbChapter.id,
|
||||
read = chapter.read || dbChapter.read,
|
||||
bookmark = chapter.bookmark || dbChapter.bookmark,
|
||||
lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead),
|
||||
dateFetch = dbChapter.dateFetch,
|
||||
sourceOrder = dbChapter.sourceOrder,
|
||||
// Merge both backup and local data with local winning
|
||||
memo = JsonObject(chapter.memo + dbChapter.memo),
|
||||
)
|
||||
}
|
||||
.partition { it.id > 0 }
|
||||
|
||||
newChapters.forEach { chapter ->
|
||||
database.chaptersQueries.insert(
|
||||
chapter.mangaId,
|
||||
chapter.url,
|
||||
chapter.name,
|
||||
chapter.scanlator,
|
||||
chapter.read,
|
||||
chapter.bookmark,
|
||||
chapter.lastPageRead,
|
||||
chapter.chapterNumber,
|
||||
chapter.sourceOrder,
|
||||
chapter.dateFetch,
|
||||
chapter.dateUpload,
|
||||
chapter.memo,
|
||||
)
|
||||
}
|
||||
existingChapters.forEach { chapter ->
|
||||
database.chaptersQueries.updateFromBackup(
|
||||
read = chapter.read,
|
||||
bookmark = chapter.bookmark,
|
||||
lastPageRead = chapter.lastPageRead,
|
||||
chapterId = chapter.id,
|
||||
memo = chapter.memo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Chapter.forComparison() =
|
||||
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L)
|
||||
|
||||
private suspend fun restoreHistory(manga: Manga, restoredHistory: List<RestoredHistory>) {
|
||||
val toUpdate = restoredHistory.mapNotNull { history ->
|
||||
val dbHistory = database.historyQueries
|
||||
.getHistoryByChapterUrlAndMangaId(history.chapterUrl, manga.id)
|
||||
.awaitAsOneOrNull()
|
||||
|
||||
if (dbHistory == null) {
|
||||
val chapter = database.chaptersQueries
|
||||
.getChapterByUrlAndMangaId(history.chapterUrl, manga.id)
|
||||
.awaitAsOneOrNull()
|
||||
// Chapter doesn't exist; skip
|
||||
?: return@mapNotNull null
|
||||
// New history entry
|
||||
return@mapNotNull Triple(chapter._id, history.readAt, history.readDuration)
|
||||
}
|
||||
|
||||
// Update history entry
|
||||
Triple(
|
||||
dbHistory.chapter_id,
|
||||
max(history.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
|
||||
.takeIf { it > 0L }
|
||||
?.let { Date(it) },
|
||||
max(history.readDuration, dbHistory.time_read) - dbHistory.time_read,
|
||||
)
|
||||
}
|
||||
|
||||
toUpdate.forEach { (chapterId, readAt, readDuration) ->
|
||||
database.historyQueries.upsert(chapterId, readAt, readDuration)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreTracking(manga: Manga, restoredTracks: List<Track>) {
|
||||
val dbTrackByTrackerId = trackRepository.getTracksByMangaId(manga.id).associateBy { it.trackerId }
|
||||
|
||||
val (existingTracks, newTracks) = restoredTracks
|
||||
.mapNotNull { track ->
|
||||
val dbTrack = dbTrackByTrackerId[track.trackerId]
|
||||
?: // New track
|
||||
return@mapNotNull track.copy(
|
||||
id = 0, // Let DB assign new ID
|
||||
mangaId = manga.id,
|
||||
)
|
||||
|
||||
if (track.forComparison() == dbTrack.forComparison()) {
|
||||
// Same state; skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
// Update to an existing track
|
||||
dbTrack.copy(
|
||||
remoteId = track.remoteId,
|
||||
libraryId = track.libraryId,
|
||||
lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead),
|
||||
)
|
||||
}
|
||||
.partition { it.id > 0 }
|
||||
|
||||
if (newTracks.isNotEmpty()) {
|
||||
trackRepository.insertAll(newTracks)
|
||||
}
|
||||
|
||||
existingTracks.forEach { track ->
|
||||
database.manga_syncQueries.update(
|
||||
track.mangaId,
|
||||
track.trackerId,
|
||||
track.remoteId,
|
||||
track.libraryId,
|
||||
track.title,
|
||||
track.lastChapterRead,
|
||||
track.totalChapters,
|
||||
track.status,
|
||||
track.score,
|
||||
track.remoteUrl,
|
||||
track.startDate,
|
||||
track.finishDate,
|
||||
track.private,
|
||||
track.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
|
||||
|
||||
private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List<String>) {
|
||||
if (excludedScanlators.isEmpty()) return
|
||||
val existingExcludedScanlators = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(manga.id)
|
||||
.awaitAsList()
|
||||
val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators }
|
||||
toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) }
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,12 @@ class CategoryRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun insertAll(categories: List<Category>) {
|
||||
database.transaction {
|
||||
categories.forEach { insert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateName(categoryId: Long, name: String) {
|
||||
database.categoriesQueries.updateName(name = name, categoryId = categoryId)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ class HistoryRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteResetHistory() {
|
||||
database.historyQueries.removeResettedHistory()
|
||||
}
|
||||
|
||||
override suspend fun upsertHistory(historyUpdate: HistoryUpdate) {
|
||||
try {
|
||||
database.historyQueries.upsert(
|
||||
|
||||
@@ -8,10 +8,12 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.lang.toLong
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.data.subscribeToList
|
||||
@@ -123,6 +125,10 @@ class MangaRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteNonLibraryManga(sourceIds: List<Long>, keepReadManga: Boolean) {
|
||||
database.mangasQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong())
|
||||
}
|
||||
|
||||
override suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>) {
|
||||
database.transaction {
|
||||
database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId)
|
||||
@@ -132,6 +138,33 @@ class MangaRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getExcludedScanlators(mangaId: Long): Set<String> {
|
||||
return database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.awaitAsList()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
|
||||
return database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.subscribeToList()
|
||||
.map { it.toSet() }
|
||||
}
|
||||
|
||||
override suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>) {
|
||||
database.transaction {
|
||||
val current = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.awaitAsList()
|
||||
.toSet()
|
||||
excludedScanlators.minus(current).forEach { scanlator ->
|
||||
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
|
||||
}
|
||||
database.excluded_scanlatorsQueries.remove(mangaId, current.minus(excludedScanlators))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun update(update: MangaUpdate): Boolean {
|
||||
return try {
|
||||
partialUpdate(update)
|
||||
|
||||
Reference in New Issue
Block a user