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:
AntsyLich
2026-09-27 22:30:51 +06:00
parent a4abb6292b
commit 306befb214
29 changed files with 580 additions and 607 deletions
+2 -1
View File
@@ -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)