Quellcode durchsuchen

Migrate History screen database calls to SQLDelight (#6933)

* Migrate History screen database call to SQLDelight

- Move all migrations to SQLDelight
- Move all tables to SQLDelight

Co-authored-by: inorichi <[email protected]>

* Changes from review comments

* Add adapters to database

* Remove logging of database version in App

* Change query name for paging source queries

* Update migrations

* Make SQLite Callback handle migration

- To ensure it updates the database

* Use SQLDelight Schema version for Callback database version

Co-authored-by: inorichi <[email protected]>
Andreas vor 2 Jahren
Ursprung
Commit
b1f46ed830
62 geänderte Dateien mit 1069 neuen und 570 gelöschten Zeilen
  1. 4 0
      app/build.gradle.kts
  2. 94 0
      app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt
  3. 20 0
      app/src/main/java/eu/kanade/data/DatabaseAdapter.kt
  4. 39 0
      app/src/main/java/eu/kanade/data/DatabaseHandler.kt
  5. 160 0
      app/src/main/java/eu/kanade/data/TransactionContext.kt
  6. 21 0
      app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt
  7. 26 0
      app/src/main/java/eu/kanade/data/history/HistoryMapper.kt
  8. 91 0
      app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt
  9. 0 43
      app/src/main/java/eu/kanade/data/history/local/HistoryPagingSource.kt
  10. 0 137
      app/src/main/java/eu/kanade/data/history/repository/HistoryRepositoryImpl.kt
  11. 26 0
      app/src/main/java/eu/kanade/data/manga/MangaMapper.kt
  12. 1 1
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  13. 16 0
      app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt
  14. 3 4
      app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt
  15. 3 4
      app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt
  16. 3 11
      app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt
  17. 2 2
      app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt
  18. 9 0
      app/src/main/java/eu/kanade/domain/history/model/History.kt
  19. 13 0
      app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt
  20. 7 11
      app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt
  21. 36 0
      app/src/main/java/eu/kanade/domain/manga/model/Manga.kt
  22. 2 3
      app/src/main/java/eu/kanade/presentation/components/MangaCover.kt
  23. 26 17
      app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt
  24. 38 1
      app/src/main/java/eu/kanade/tachiyomi/AppModule.kt
  25. 5 2
      app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt
  26. 10 80
      app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt
  27. 0 56
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt
  28. 2 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt
  29. 0 9
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt
  30. 0 38
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt
  31. 0 20
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt
  32. 0 12
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt
  33. 0 49
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt
  34. 0 40
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt
  35. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt
  36. 3 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  37. 8 8
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt
  38. 9 19
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt
  39. 3 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt
  40. 6 0
      app/src/main/sqldelight/data/categories.sq
  41. 26 0
      app/src/main/sqldelight/data/chapters.sq
  42. 35 0
      app/src/main/sqldelight/data/history.sq
  43. 18 0
      app/src/main/sqldelight/data/manga_sync.sq
  44. 28 0
      app/src/main/sqldelight/data/mangas.sq
  45. 9 0
      app/src/main/sqldelight/data/mangas_categories.sq
  46. 6 0
      app/src/main/sqldelight/migrations/1.sqm
  47. 11 0
      app/src/main/sqldelight/migrations/10.sqm
  48. 2 0
      app/src/main/sqldelight/migrations/11.sqm
  49. 9 0
      app/src/main/sqldelight/migrations/12.sqm
  50. 3 0
      app/src/main/sqldelight/migrations/13.sqm
  51. 149 0
      app/src/main/sqldelight/migrations/14.sqm
  52. 10 0
      app/src/main/sqldelight/migrations/2.sqm
  53. 2 0
      app/src/main/sqldelight/migrations/3.sqm
  54. 2 0
      app/src/main/sqldelight/migrations/4.sqm
  55. 2 0
      app/src/main/sqldelight/migrations/5.sqm
  56. 2 0
      app/src/main/sqldelight/migrations/6.sqm
  57. 9 0
      app/src/main/sqldelight/migrations/7.sqm
  58. 5 0
      app/src/main/sqldelight/migrations/8.sqm
  59. 2 0
      app/src/main/sqldelight/migrations/9.sqm
  60. 46 0
      app/src/main/sqldelight/view/historyView.sq
  61. 1 0
      build.gradle.kts
  62. 5 0
      gradle/libs.versions.toml

+ 4 - 0
app/build.gradle.kts

@@ -6,6 +6,7 @@ plugins {
     kotlin("android")
     kotlin("plugin.serialization")
     id("com.github.zellius.shortcut-helper")
+    id("com.squareup.sqldelight")
 }
 
 if (gradle.startParameter.taskRequests.toString().contains("Standard")) {
@@ -147,6 +148,9 @@ dependencies {
     implementation(androidx.paging.runtime)
     implementation(androidx.paging.compose)
 
+    implementation(libs.sqldelight.android.driver)
+    implementation(libs.sqldelight.coroutines)
+    implementation(libs.sqldelight.android.paging)
 
     implementation(kotlinx.reflect)
 

+ 94 - 0
app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt

@@ -0,0 +1,94 @@
+package eu.kanade.data
+
+import androidx.paging.PagingSource
+import com.squareup.sqldelight.Query
+import com.squareup.sqldelight.Transacter
+import com.squareup.sqldelight.android.paging3.QueryPagingSource
+import com.squareup.sqldelight.db.SqlDriver
+import com.squareup.sqldelight.runtime.coroutines.asFlow
+import com.squareup.sqldelight.runtime.coroutines.mapToList
+import com.squareup.sqldelight.runtime.coroutines.mapToOne
+import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull
+import eu.kanade.tachiyomi.Database
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+class AndroidDatabaseHandler(
+    val db: Database,
+    private val driver: SqlDriver,
+    val queryDispatcher: CoroutineDispatcher = Dispatchers.IO,
+    val transactionDispatcher: CoroutineDispatcher = queryDispatcher
+) : DatabaseHandler {
+
+    val suspendingTransactionId = ThreadLocal<Int>()
+
+    override suspend fun <T> await(inTransaction: Boolean, block: suspend Database.() -> T): T {
+        return dispatch(inTransaction, block)
+    }
+
+    override suspend fun <T : Any> awaitList(
+        inTransaction: Boolean,
+        block: suspend Database.() -> Query<T>
+    ): List<T> {
+        return dispatch(inTransaction) { block(db).executeAsList() }
+    }
+
+    override suspend fun <T : Any> awaitOne(
+        inTransaction: Boolean,
+        block: suspend Database.() -> Query<T>
+    ): T {
+        return dispatch(inTransaction) { block(db).executeAsOne() }
+    }
+
+    override suspend fun <T : Any> awaitOneOrNull(
+        inTransaction: Boolean,
+        block: suspend Database.() -> Query<T>
+    ): T? {
+        return dispatch(inTransaction) { block(db).executeAsOneOrNull() }
+    }
+
+    override fun <T : Any> subscribeToList(block: Database.() -> Query<T>): Flow<List<T>> {
+        return block(db).asFlow().mapToList(queryDispatcher)
+    }
+
+    override fun <T : Any> subscribeToOne(block: Database.() -> Query<T>): Flow<T> {
+        return block(db).asFlow().mapToOne(queryDispatcher)
+    }
+
+    override fun <T : Any> subscribeToOneOrNull(block: Database.() -> Query<T>): Flow<T?> {
+        return block(db).asFlow().mapToOneOrNull(queryDispatcher)
+    }
+
+    override fun <T : Any> subscribeToPagingSource(
+        countQuery: Database.() -> Query<Long>,
+        transacter: Database.() -> Transacter,
+        queryProvider: Database.(Long, Long) -> Query<T>
+    ): PagingSource<Long, T> {
+        return QueryPagingSource(
+            countQuery = countQuery(db),
+            transacter = transacter(db),
+            dispatcher = queryDispatcher,
+            queryProvider = { limit, offset ->
+                queryProvider.invoke(db, limit, offset)
+            }
+        )
+    }
+
+    private suspend fun <T> dispatch(inTransaction: Boolean, block: suspend Database.() -> T): T {
+        // Create a transaction if needed and run the calling block inside it.
+        if (inTransaction) {
+            return withTransaction { block(db) }
+        }
+
+        // If we're currently in the transaction thread, there's no need to dispatch our query.
+        if (driver.currentTransaction() != null) {
+            return block(db)
+        }
+
+        // Get the current database context and run the calling block.
+        val context = getCurrentDatabaseContext()
+        return withContext(context) { block(db) }
+    }
+}

+ 20 - 0
app/src/main/java/eu/kanade/data/DatabaseAdapter.kt

@@ -0,0 +1,20 @@
+package eu.kanade.data
+
+import com.squareup.sqldelight.ColumnAdapter
+import java.util.*
+
+val dateAdapter = object : ColumnAdapter<Date, Long> {
+    override fun decode(databaseValue: Long): Date = Date(databaseValue)
+    override fun encode(value: Date): Long = value.time
+}
+
+private const val listOfStringsSeparator = ", "
+val listOfStringsAdapter = object : ColumnAdapter<List<String>, String> {
+    override fun decode(databaseValue: String) =
+        if (databaseValue.isEmpty()) {
+            listOf()
+        } else {
+            databaseValue.split(listOfStringsSeparator)
+        }
+    override fun encode(value: List<String>) = value.joinToString(separator = listOfStringsSeparator)
+}

+ 39 - 0
app/src/main/java/eu/kanade/data/DatabaseHandler.kt

@@ -0,0 +1,39 @@
+package eu.kanade.data
+
+import androidx.paging.PagingSource
+import com.squareup.sqldelight.Query
+import com.squareup.sqldelight.Transacter
+import eu.kanade.tachiyomi.Database
+import kotlinx.coroutines.flow.Flow
+
+interface DatabaseHandler {
+
+    suspend fun <T> await(inTransaction: Boolean = false, block: suspend Database.() -> T): T
+
+    suspend fun <T : Any> awaitList(
+        inTransaction: Boolean = false,
+        block: suspend Database.() -> Query<T>
+    ): List<T>
+
+    suspend fun <T : Any> awaitOne(
+        inTransaction: Boolean = false,
+        block: suspend Database.() -> Query<T>
+    ): T
+
+    suspend fun <T : Any> awaitOneOrNull(
+        inTransaction: Boolean = false,
+        block: suspend Database.() -> Query<T>
+    ): T?
+
+    fun <T : Any> subscribeToList(block: Database.() -> Query<T>): Flow<List<T>>
+
+    fun <T : Any> subscribeToOne(block: Database.() -> Query<T>): Flow<T>
+
+    fun <T : Any> subscribeToOneOrNull(block: Database.() -> Query<T>): Flow<T?>
+
+    fun <T : Any> subscribeToPagingSource(
+        countQuery: Database.() -> Query<Long>,
+        transacter: Database.() -> Transacter,
+        queryProvider: Database.(Long, Long) -> Query<T>
+    ): PagingSource<Long, T>
+}

+ 160 - 0
app/src/main/java/eu/kanade/data/TransactionContext.kt

@@ -0,0 +1,160 @@
+package eu.kanade.data
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asContextElement
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.coroutines.ContinuationInterceptor
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.coroutineContext
+import kotlin.coroutines.resume
+
+/**
+ * Returns the transaction dispatcher if we are on a transaction, or the database dispatchers.
+ */
+internal suspend fun AndroidDatabaseHandler.getCurrentDatabaseContext(): CoroutineContext {
+    return coroutineContext[TransactionElement]?.transactionDispatcher ?: queryDispatcher
+}
+
+/**
+ * Calls the specified suspending [block] in a database transaction. The transaction will be
+ * marked as successful unless an exception is thrown in the suspending [block] or the coroutine
+ * is cancelled.
+ *
+ * SQLDelight will only perform at most one transaction at a time, additional transactions are queued
+ * and executed on a first come, first serve order.
+ *
+ * Performing blocking database operations is not permitted in a coroutine scope other than the
+ * one received by the suspending block. It is recommended that all [Dao] function invoked within
+ * the [block] be suspending functions.
+ *
+ * The dispatcher used to execute the given [block] will utilize threads from SQLDelight's query executor.
+ */
+internal suspend fun <T> AndroidDatabaseHandler.withTransaction(block: suspend () -> T): T {
+    // Use inherited transaction context if available, this allows nested suspending transactions.
+    val transactionContext =
+        coroutineContext[TransactionElement]?.transactionDispatcher ?: createTransactionContext()
+    return withContext(transactionContext) {
+        val transactionElement = coroutineContext[TransactionElement]!!
+        transactionElement.acquire()
+        try {
+            db.transactionWithResult {
+                runBlocking(transactionContext) {
+                    block()
+                }
+            }
+        } finally {
+            transactionElement.release()
+        }
+    }
+}
+
+/**
+ * Creates a [CoroutineContext] for performing database operations within a coroutine transaction.
+ *
+ * The context is a combination of a dispatcher, a [TransactionElement] and a thread local element.
+ *
+ * * The dispatcher will dispatch coroutines to a single thread that is taken over from the SQLDelight
+ * query executor. If the coroutine context is switched, suspending DAO functions will be able to
+ * dispatch to the transaction thread.
+ *
+ * * The [TransactionElement] serves as an indicator for inherited context, meaning, if there is a
+ * switch of context, suspending DAO methods will be able to use the indicator to dispatch the
+ * database operation to the transaction thread.
+ *
+ * * The thread local element serves as a second indicator and marks threads that are used to
+ * execute coroutines within the coroutine transaction, more specifically it allows us to identify
+ * if a blocking DAO method is invoked within the transaction coroutine. Never assign meaning to
+ * this value, for now all we care is if its present or not.
+ */
+private suspend fun AndroidDatabaseHandler.createTransactionContext(): CoroutineContext {
+    val controlJob = Job()
+    // make sure to tie the control job to this context to avoid blocking the transaction if
+    // context get cancelled before we can even start using this job. Otherwise, the acquired
+    // transaction thread will forever wait for the controlJob to be cancelled.
+    // see b/148181325
+    coroutineContext[Job]?.invokeOnCompletion {
+        controlJob.cancel()
+    }
+
+    val dispatcher = transactionDispatcher.acquireTransactionThread(controlJob)
+    val transactionElement = TransactionElement(controlJob, dispatcher)
+    val threadLocalElement =
+        suspendingTransactionId.asContextElement(System.identityHashCode(controlJob))
+    return dispatcher + transactionElement + threadLocalElement
+}
+
+/**
+ * Acquires a thread from the executor and returns a [ContinuationInterceptor] to dispatch
+ * coroutines to the acquired thread. The [controlJob] is used to control the release of the
+ * thread by cancelling the job.
+ */
+private suspend fun CoroutineDispatcher.acquireTransactionThread(
+    controlJob: Job
+): ContinuationInterceptor {
+    return suspendCancellableCoroutine { continuation ->
+        continuation.invokeOnCancellation {
+            // We got cancelled while waiting to acquire a thread, we can't stop our attempt to
+            // acquire a thread, but we can cancel the controlling job so once it gets acquired it
+            // is quickly released.
+            controlJob.cancel()
+        }
+        try {
+            dispatch(EmptyCoroutineContext) {
+                runBlocking {
+                    // Thread acquired, resume coroutine.
+                    continuation.resume(coroutineContext[ContinuationInterceptor]!!)
+                    controlJob.join()
+                }
+            }
+        } catch (ex: RejectedExecutionException) {
+            // Couldn't acquire a thread, cancel coroutine.
+            continuation.cancel(
+                IllegalStateException(
+                    "Unable to acquire a thread to perform the database transaction.", ex
+                )
+            )
+        }
+    }
+}
+
+/**
+ * A [CoroutineContext.Element] that indicates there is an on-going database transaction.
+ */
+private class TransactionElement(
+    private val transactionThreadControlJob: Job,
+    val transactionDispatcher: ContinuationInterceptor
+) : CoroutineContext.Element {
+
+    companion object Key : CoroutineContext.Key<TransactionElement>
+
+    override val key: CoroutineContext.Key<TransactionElement>
+        get() = TransactionElement
+
+    /**
+     * Number of transactions (including nested ones) started with this element.
+     * Call [acquire] to increase the count and [release] to decrease it. If the count reaches zero
+     * when [release] is invoked then the transaction job is cancelled and the transaction thread
+     * is released.
+     */
+    private val referenceCount = AtomicInteger(0)
+
+    fun acquire() {
+        referenceCount.incrementAndGet()
+    }
+
+    fun release() {
+        val count = referenceCount.decrementAndGet()
+        if (count < 0) {
+            throw IllegalStateException("Transaction was never started or was already released.")
+        } else if (count == 0) {
+            // Cancel the job that controls the transaction thread, causing it to be released.
+            transactionThreadControlJob.cancel()
+        }
+    }
+}

+ 21 - 0
app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt

@@ -0,0 +1,21 @@
+package eu.kanade.data.chapter
+
+import eu.kanade.domain.chapter.model.Chapter
+
+val chapterMapper: (Long, Long, String, String, String?, Boolean, Boolean, Long, Float, Long, Long, Long) -> Chapter =
+    { id, mangaId, url, name, scanlator, read, bookmark, lastPageRead, chapterNumber, sourceOrder, dateFetch, dateUpload ->
+        Chapter(
+            id = id,
+            mangaId = mangaId,
+            read = read,
+            bookmark = bookmark,
+            lastPageRead = lastPageRead,
+            dateFetch = dateFetch,
+            sourceOrder = sourceOrder,
+            url = url,
+            name = name,
+            dateUpload = dateUpload,
+            chapterNumber = chapterNumber,
+            scanlator = scanlator,
+        )
+    }

+ 26 - 0
app/src/main/java/eu/kanade/data/history/HistoryMapper.kt

@@ -0,0 +1,26 @@
+package eu.kanade.data.history
+
+import eu.kanade.domain.history.model.History
+import eu.kanade.domain.history.model.HistoryWithRelations
+import java.util.*
+
+val historyMapper: (Long, Long, Date?, Date?) -> History = { id, chapterId, readAt, _ ->
+    History(
+        id = id,
+        chapterId = chapterId,
+        readAt = readAt,
+    )
+}
+
+val historyWithRelationsMapper: (Long, Long, Long, String, String?, Float, Date?) -> HistoryWithRelations = {
+    historyId, mangaId, chapterId, title, thumbnailUrl, chapterNumber, readAt ->
+    HistoryWithRelations(
+        id = historyId,
+        chapterId = chapterId,
+        mangaId = mangaId,
+        title = title,
+        thumbnailUrl = thumbnailUrl ?: "",
+        chapterNumber = chapterNumber,
+        readAt = readAt
+    )
+}

+ 91 - 0
app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt

@@ -0,0 +1,91 @@
+package eu.kanade.data.history
+
+import androidx.paging.PagingSource
+import eu.kanade.data.DatabaseHandler
+import eu.kanade.data.chapter.chapterMapper
+import eu.kanade.data.manga.mangaMapper
+import eu.kanade.domain.chapter.model.Chapter
+import eu.kanade.domain.history.model.HistoryWithRelations
+import eu.kanade.domain.history.repository.HistoryRepository
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.tachiyomi.util.system.logcat
+
+class HistoryRepositoryImpl(
+    private val handler: DatabaseHandler
+) : HistoryRepository {
+
+    override fun getHistory(query: String): PagingSource<Long, HistoryWithRelations> {
+        return handler.subscribeToPagingSource(
+            countQuery = { historyViewQueries.countHistory(query) },
+            transacter = { historyViewQueries },
+            queryProvider = { limit, offset ->
+                historyViewQueries.history(query, limit, offset, historyWithRelationsMapper)
+            }
+        )
+    }
+
+    override suspend fun getNextChapterForManga(mangaId: Long, chapterId: Long): Chapter? {
+        val chapter = handler.awaitOne { chaptersQueries.getChapterById(chapterId, chapterMapper) }
+        val manga = handler.awaitOne { mangasQueries.getMangaById(mangaId, mangaMapper) }
+
+        if (!chapter.read) {
+            return chapter
+        }
+
+        val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
+            Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) }
+            Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapterNumber.compareTo(c2.chapterNumber) }
+            Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) }
+            else -> throw NotImplementedError("Unknown sorting method")
+        }
+
+        val chapters = handler.awaitList { chaptersQueries.getChapterByMangaId(mangaId, chapterMapper) }
+            .sortedWith(sortFunction)
+
+        val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
+        return when (manga.sorting) {
+            Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
+            Manga.CHAPTER_SORTING_NUMBER -> {
+                val chapterNumber = chapter.chapterNumber
+
+                ((currChapterIndex + 1) until chapters.size)
+                    .map { chapters[it] }
+                    .firstOrNull {
+                        it.chapterNumber > chapterNumber &&
+                            it.chapterNumber <= chapterNumber + 1
+                    }
+            }
+            Manga.CHAPTER_SORTING_UPLOAD_DATE -> {
+                chapters.drop(currChapterIndex + 1)
+                    .firstOrNull { it.dateUpload >= chapter.dateUpload }
+            }
+            else -> throw NotImplementedError("Unknown sorting method")
+        }
+    }
+
+    override suspend fun resetHistory(historyId: Long) {
+        try {
+            handler.await { historyQueries.resetHistoryById(historyId) }
+        } catch (e: Exception) {
+            logcat(throwable = e)
+        }
+    }
+
+    override suspend fun resetHistoryByMangaId(mangaId: Long) {
+        try {
+            handler.await { historyQueries.resetHistoryByMangaId(mangaId) }
+        } catch (e: Exception) {
+            logcat(throwable = e)
+        }
+    }
+
+    override suspend fun deleteAllHistory(): Boolean {
+        return try {
+            handler.await { historyQueries.removeAllHistory() }
+            true
+        } catch (e: Exception) {
+            logcat(throwable = e)
+            false
+        }
+    }
+}

+ 0 - 43
app/src/main/java/eu/kanade/data/history/local/HistoryPagingSource.kt

@@ -1,43 +0,0 @@
-package eu.kanade.data.history.local
-
-import androidx.paging.PagingSource
-import androidx.paging.PagingState
-import eu.kanade.domain.history.repository.HistoryRepository
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import logcat.logcat
-
-class HistoryPagingSource(
-    private val repository: HistoryRepository,
-    private val query: String
-) : PagingSource<Int, MangaChapterHistory>() {
-
-    override fun getRefreshKey(state: PagingState<Int, MangaChapterHistory>): Int? {
-        return state.anchorPosition?.let { anchorPosition ->
-            val anchorPage = state.closestPageToPosition(anchorPosition)
-            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
-        }
-    }
-
-    override suspend fun load(params: LoadParams<Int>): LoadResult.Page<Int, MangaChapterHistory> {
-        val nextPageNumber = params.key ?: 0
-        logcat { "Loading page $nextPageNumber" }
-
-        val response = repository.getHistory(PAGE_SIZE, nextPageNumber, query)
-
-        val nextKey = if (response.size == 25) {
-            nextPageNumber + 1
-        } else {
-            null
-        }
-
-        return LoadResult.Page(
-            data = response,
-            prevKey = null,
-            nextKey = nextKey
-        )
-    }
-
-    companion object {
-        const val PAGE_SIZE = 25
-    }
-}

+ 0 - 137
app/src/main/java/eu/kanade/data/history/repository/HistoryRepositoryImpl.kt

@@ -1,137 +0,0 @@
-package eu.kanade.data.history.repository
-
-import eu.kanade.data.history.local.HistoryPagingSource
-import eu.kanade.domain.history.repository.HistoryRepository
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.data.database.models.Chapter
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.data.database.tables.HistoryTable
-import eu.kanade.tachiyomi.util.system.logcat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.withContext
-import rx.Subscription
-import rx.schedulers.Schedulers
-import java.util.*
-
-class HistoryRepositoryImpl(
-    private val db: DatabaseHelper
-) : HistoryRepository {
-
-    /**
-     * Used to observe changes in the History table
-     * as RxJava isn't supported in Paging 3
-     */
-    private var subscription: Subscription? = null
-
-    /**
-     * Paging Source for history table
-     */
-    override fun getHistory(query: String): HistoryPagingSource {
-        subscription?.unsubscribe()
-        val pagingSource = HistoryPagingSource(this, query)
-        subscription = db.db
-            .observeChangesInTable(HistoryTable.TABLE)
-            .observeOn(Schedulers.io())
-            .subscribe {
-                pagingSource.invalidate()
-            }
-        return pagingSource
-    }
-
-    override suspend fun getHistory(limit: Int, page: Int, query: String) = coroutineScope {
-        withContext(Dispatchers.IO) {
-            // Set date limit for recent manga
-            val calendar = Calendar.getInstance().apply {
-                time = Date()
-                add(Calendar.YEAR, -50)
-            }
-
-            db.getRecentManga(calendar.time, limit, page * limit, query)
-                .executeAsBlocking()
-        }
-    }
-
-    override suspend fun getNextChapterForManga(manga: Manga, chapter: Chapter): Chapter? = coroutineScope {
-        withContext(Dispatchers.IO) {
-            if (!chapter.read) {
-                return@withContext chapter
-            }
-
-            val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
-                Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
-                Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
-                Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
-                else -> throw NotImplementedError("Unknown sorting method")
-            }
-
-            val chapters = db.getChapters(manga)
-                .executeAsBlocking()
-                .sortedWith { c1, c2 -> sortFunction(c1, c2) }
-
-            val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
-            return@withContext when (manga.sorting) {
-                Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
-                Manga.CHAPTER_SORTING_NUMBER -> {
-                    val chapterNumber = chapter.chapter_number
-
-                    ((currChapterIndex + 1) until chapters.size)
-                        .map { chapters[it] }
-                        .firstOrNull {
-                            it.chapter_number > chapterNumber &&
-                                it.chapter_number <= chapterNumber + 1
-                        }
-                }
-                Manga.CHAPTER_SORTING_UPLOAD_DATE -> {
-                    chapters.drop(currChapterIndex + 1)
-                        .firstOrNull { it.date_upload >= chapter.date_upload }
-                }
-                else -> throw NotImplementedError("Unknown sorting method")
-            }
-        }
-    }
-
-    override suspend fun resetHistory(history: History): Boolean = coroutineScope {
-        withContext(Dispatchers.IO) {
-            try {
-                history.last_read = 0
-                db.upsertHistoryLastRead(history)
-                    .executeAsBlocking()
-                true
-            } catch (e: Throwable) {
-                logcat(throwable = e)
-                false
-            }
-        }
-    }
-
-    override suspend fun resetHistoryByMangaId(mangaId: Long): Boolean = coroutineScope {
-        withContext(Dispatchers.IO) {
-            try {
-                val history = db.getHistoryByMangaId(mangaId)
-                    .executeAsBlocking()
-                history.forEach { it.last_read = 0 }
-                db.upsertHistoryLastRead(history)
-                    .executeAsBlocking()
-                true
-            } catch (e: Throwable) {
-                logcat(throwable = e)
-                false
-            }
-        }
-    }
-
-    override suspend fun deleteAllHistory(): Boolean = coroutineScope {
-        withContext(Dispatchers.IO) {
-            try {
-                db.dropHistoryTable()
-                    .executeAsBlocking()
-                true
-            } catch (e: Throwable) {
-                logcat(throwable = e)
-                false
-            }
-        }
-    }
-}

+ 26 - 0
app/src/main/java/eu/kanade/data/manga/MangaMapper.kt

@@ -0,0 +1,26 @@
+package eu.kanade.data.manga
+
+import eu.kanade.domain.manga.model.Manga
+
+val mangaMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long) -> Manga =
+    { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewer, chapterFlags, coverLastModified, dateAdded ->
+        Manga(
+            id = id,
+            source = source,
+            favorite = favorite,
+            lastUpdate = lastUpdate ?: 0,
+            dateAdded = dateAdded,
+            viewerFlags = viewer,
+            chapterFlags = chapterFlags,
+            coverLastModified = coverLastModified,
+            url = url,
+            title = title,
+            artist = artist,
+            author = author,
+            description = description,
+            genre = genre,
+            status = status,
+            thumbnailUrl = thumbnailUrl,
+            initialized = initialized,
+        )
+    }

+ 1 - 1
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -1,6 +1,6 @@
 package eu.kanade.domain
 
-import eu.kanade.data.history.repository.HistoryRepositoryImpl
+import eu.kanade.data.history.HistoryRepositoryImpl
 import eu.kanade.domain.history.interactor.DeleteHistoryTable
 import eu.kanade.domain.history.interactor.GetHistory
 import eu.kanade.domain.history.interactor.GetNextChapterForManga

+ 16 - 0
app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt

@@ -0,0 +1,16 @@
+package eu.kanade.domain.chapter.model
+
+data class Chapter(
+    val id: Long,
+    val mangaId: Long,
+    val read: Boolean,
+    val bookmark: Boolean,
+    val lastPageRead: Long,
+    val dateFetch: Long,
+    val sourceOrder: Long,
+    val url: String,
+    val name: String,
+    val dateUpload: Long,
+    val chapterNumber: Float,
+    val scanlator: String?
+)

+ 3 - 4
app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt

@@ -3,18 +3,17 @@ package eu.kanade.domain.history.interactor
 import androidx.paging.Pager
 import androidx.paging.PagingConfig
 import androidx.paging.PagingData
-import eu.kanade.data.history.local.HistoryPagingSource
+import eu.kanade.domain.history.model.HistoryWithRelations
 import eu.kanade.domain.history.repository.HistoryRepository
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
 import kotlinx.coroutines.flow.Flow
 
 class GetHistory(
     private val repository: HistoryRepository
 ) {
 
-    fun subscribe(query: String): Flow<PagingData<MangaChapterHistory>> {
+    fun subscribe(query: String): Flow<PagingData<HistoryWithRelations>> {
         return Pager(
-            PagingConfig(pageSize = HistoryPagingSource.PAGE_SIZE)
+            PagingConfig(pageSize = 25)
         ) {
             repository.getHistory(query)
         }.flow

+ 3 - 4
app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt

@@ -1,14 +1,13 @@
 package eu.kanade.domain.history.interactor
 
+import eu.kanade.domain.chapter.model.Chapter
 import eu.kanade.domain.history.repository.HistoryRepository
-import eu.kanade.tachiyomi.data.database.models.Chapter
-import eu.kanade.tachiyomi.data.database.models.Manga
 
 class GetNextChapterForManga(
     private val repository: HistoryRepository
 ) {
 
-    suspend fun await(manga: Manga, chapter: Chapter): Chapter? {
-        return repository.getNextChapterForManga(manga, chapter)
+    suspend fun await(mangaId: Long, chapterId: Long): Chapter? {
+        return repository.getNextChapterForManga(mangaId, chapterId)
     }
 }

+ 3 - 11
app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt

@@ -1,21 +1,13 @@
 package eu.kanade.domain.history.interactor
 
+import eu.kanade.domain.history.model.HistoryWithRelations
 import eu.kanade.domain.history.repository.HistoryRepository
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.HistoryImpl
 
 class RemoveHistoryById(
     private val repository: HistoryRepository
 ) {
 
-    suspend fun await(history: History): Boolean {
-        // Workaround for list not freaking out when changing reference varaible
-        val history = HistoryImpl().apply {
-            id = history.id
-            chapter_id = history.chapter_id
-            last_read = history.last_read
-            time_read = history.time_read
-        }
-        return repository.resetHistory(history)
+    suspend fun await(history: HistoryWithRelations) {
+        repository.resetHistory(history.id)
     }
 }

+ 2 - 2
app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt

@@ -6,7 +6,7 @@ class RemoveHistoryByMangaId(
     private val repository: HistoryRepository
 ) {
 
-    suspend fun await(mangaId: Long): Boolean {
-        return repository.resetHistoryByMangaId(mangaId)
+    suspend fun await(mangaId: Long) {
+        repository.resetHistoryByMangaId(mangaId)
     }
 }

+ 9 - 0
app/src/main/java/eu/kanade/domain/history/model/History.kt

@@ -0,0 +1,9 @@
+package eu.kanade.domain.history.model
+
+import java.util.*
+
+data class History(
+    val id: Long?,
+    val chapterId: Long,
+    val readAt: Date?
+)

+ 13 - 0
app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt

@@ -0,0 +1,13 @@
+package eu.kanade.domain.history.model
+
+import java.util.*
+
+data class HistoryWithRelations(
+    val id: Long,
+    val chapterId: Long,
+    val mangaId: Long,
+    val title: String,
+    val thumbnailUrl: String,
+    val chapterNumber: Float,
+    val readAt: Date?
+)

+ 7 - 11
app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt

@@ -1,22 +1,18 @@
 package eu.kanade.domain.history.repository
 
-import eu.kanade.data.history.local.HistoryPagingSource
-import eu.kanade.tachiyomi.data.database.models.Chapter
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
+import androidx.paging.PagingSource
+import eu.kanade.domain.chapter.model.Chapter
+import eu.kanade.domain.history.model.HistoryWithRelations
 
 interface HistoryRepository {
 
-    fun getHistory(query: String): HistoryPagingSource
+    fun getHistory(query: String): PagingSource<Long, HistoryWithRelations>
 
-    suspend fun getHistory(limit: Int, page: Int, query: String): List<MangaChapterHistory>
+    suspend fun getNextChapterForManga(mangaId: Long, chapterId: Long): Chapter?
 
-    suspend fun getNextChapterForManga(manga: Manga, chapter: Chapter): Chapter?
+    suspend fun resetHistory(historyId: Long)
 
-    suspend fun resetHistory(history: History): Boolean
-
-    suspend fun resetHistoryByMangaId(mangaId: Long): Boolean
+    suspend fun resetHistoryByMangaId(mangaId: Long)
 
     suspend fun deleteAllHistory(): Boolean
 }

+ 36 - 0
app/src/main/java/eu/kanade/domain/manga/model/Manga.kt

@@ -0,0 +1,36 @@
+package eu.kanade.domain.manga.model
+
+data class Manga(
+    val id: Long,
+    val source: Long,
+    val favorite: Boolean,
+    val lastUpdate: Long,
+    val dateAdded: Long,
+    val viewerFlags: Long,
+    val chapterFlags: Long,
+    val coverLastModified: Long,
+    val url: String,
+    val title: String,
+    val artist: String?,
+    val author: String?,
+    val description: String?,
+    val genre: List<String>?,
+    val status: Long,
+    val thumbnailUrl: String?,
+    val initialized: Boolean
+) {
+
+    val sorting: Long
+        get() = chapterFlags and CHAPTER_SORTING_MASK
+
+    companion object {
+
+        // Generic filter that does not filter anything
+        const val SHOW_ALL = 0x00000000L
+
+        const val CHAPTER_SORTING_SOURCE = 0x00000000L
+        const val CHAPTER_SORTING_NUMBER = 0x00000100L
+        const val CHAPTER_SORTING_UPLOAD_DATE = 0x00000200L
+        const val CHAPTER_SORTING_MASK = 0x00000300L
+    }
+}

+ 2 - 3
app/src/main/java/eu/kanade/presentation/components/MangaCover.kt

@@ -9,7 +9,6 @@ import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.dp
 import coil.compose.AsyncImage
-import eu.kanade.tachiyomi.data.database.models.Manga
 
 enum class MangaCoverAspect(val ratio: Float) {
     SQUARE(1f / 1f),
@@ -19,13 +18,13 @@ enum class MangaCoverAspect(val ratio: Float) {
 @Composable
 fun MangaCover(
     modifier: Modifier = Modifier,
-    manga: Manga,
+    data: String?,
     aspect: MangaCoverAspect,
     contentDescription: String = "",
     shape: Shape = RoundedCornerShape(4.dp)
 ) {
     AsyncImage(
-        model = manga,
+        model = data,
         contentDescription = contentDescription,
         modifier = modifier
             .aspectRatio(aspect.ratio)

+ 26 - 17
app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt

@@ -1,6 +1,7 @@
 package eu.kanade.presentation.history
 
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
@@ -42,13 +43,13 @@ import androidx.core.text.buildSpannedString
 import androidx.paging.compose.LazyPagingItems
 import androidx.paging.compose.collectAsLazyPagingItems
 import androidx.paging.compose.items
+import eu.kanade.domain.history.model.HistoryWithRelations
 import eu.kanade.presentation.components.EmptyScreen
 import eu.kanade.presentation.components.MangaCover
 import eu.kanade.presentation.components.MangaCoverAspect
 import eu.kanade.presentation.theme.TachiyomiTheme
 import eu.kanade.presentation.util.horizontalPadding
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter
 import eu.kanade.tachiyomi.ui.recent.history.UiModel
@@ -71,9 +72,9 @@ val chapterFormatter = DecimalFormat(
 fun HistoryScreen(
     composeView: ComposeView,
     presenter: HistoryPresenter,
-    onClickItem: (MangaChapterHistory) -> Unit,
-    onClickResume: (MangaChapterHistory) -> Unit,
-    onClickDelete: (MangaChapterHistory, Boolean) -> Unit,
+    onClickItem: (HistoryWithRelations) -> Unit,
+    onClickResume: (HistoryWithRelations) -> Unit,
+    onClickDelete: (HistoryWithRelations, Boolean) -> Unit,
 ) {
     val nestedSrollInterop = rememberNestedScrollInteropConnection(composeView)
     TachiyomiTheme {
@@ -104,16 +105,16 @@ fun HistoryScreen(
 @Composable
 fun HistoryContent(
     history: LazyPagingItems<UiModel>,
-    onClickItem: (MangaChapterHistory) -> Unit,
-    onClickResume: (MangaChapterHistory) -> Unit,
-    onClickDelete: (MangaChapterHistory, Boolean) -> Unit,
+    onClickItem: (HistoryWithRelations) -> Unit,
+    onClickResume: (HistoryWithRelations) -> Unit,
+    onClickDelete: (HistoryWithRelations, Boolean) -> Unit,
     preferences: PreferencesHelper = Injekt.get(),
     nestedScroll: NestedScrollConnection
 ) {
     val relativeTime: Int = remember { preferences.relativeTime().get() }
     val dateFormat: DateFormat = remember { preferences.dateFormat() }
 
-    val (removeState, setRemoveState) = remember { mutableStateOf<MangaChapterHistory?>(null) }
+    val (removeState, setRemoveState) = remember { mutableStateOf<HistoryWithRelations?>(null) }
 
     val scrollState = rememberLazyListState()
     LazyColumn(
@@ -132,7 +133,7 @@ fun HistoryContent(
                         dateFormat = dateFormat
                     )
                 }
-                is UiModel.History -> {
+                is UiModel.Item -> {
                     val value = item.item
                     HistoryItem(
                         modifier = Modifier.animateItemPlacement(),
@@ -189,7 +190,7 @@ fun HistoryHeader(
 @Composable
 fun HistoryItem(
     modifier: Modifier = Modifier,
-    history: MangaChapterHistory,
+    history: HistoryWithRelations,
     onClickItem: () -> Unit,
     onClickResume: () -> Unit,
     onClickDelete: () -> Unit,
@@ -203,7 +204,7 @@ fun HistoryItem(
     ) {
         MangaCover(
             modifier = Modifier.fillMaxHeight(),
-            manga = history.manga,
+            data = history.thumbnailUrl,
             aspect = MangaCoverAspect.COVER
         )
         Column(
@@ -215,7 +216,7 @@ fun HistoryItem(
                 color = MaterialTheme.colorScheme.onSurface,
             )
             Text(
-                text = history.manga.title,
+                text = history.title,
                 maxLines = 2,
                 overflow = TextOverflow.Ellipsis,
                 style = textStyle.copy(fontWeight = FontWeight.SemiBold)
@@ -223,15 +224,15 @@ fun HistoryItem(
             Row {
                 Text(
                     text = buildSpannedString {
-                        if (history.chapter.chapter_number > -1) {
+                        if (history.chapterNumber > -1) {
                             append(
                                 stringResource(
                                     R.string.history_prefix,
-                                    chapterFormatter.format(history.chapter.chapter_number)
+                                    chapterFormatter.format(history.chapterNumber)
                                 )
                             )
                         }
-                        append(Date(history.history.last_read).toTimestampString())
+                        append(history.readAt?.toTimestampString())
                     }.toString(),
                     modifier = Modifier.padding(top = 2.dp),
                     style = textStyle
@@ -270,14 +271,22 @@ fun RemoveHistoryDialog(
             Column {
                 Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description))
                 Row(
-                    modifier = Modifier.toggleable(value = removeEverything, onValueChange = removeEverythingState),
+                    modifier = Modifier
+                        .padding(top = 16.dp)
+                        .toggleable(
+                            interactionSource = remember { MutableInteractionSource() },
+                            indication = null,
+                            value = removeEverything,
+                            onValueChange = removeEverythingState
+                        ),
                     verticalAlignment = Alignment.CenterVertically
                 ) {
                     Checkbox(
                         checked = removeEverything,
-                        onCheckedChange = removeEverythingState,
+                        onCheckedChange = null,
                     )
                     Text(
+                        modifier = Modifier.padding(start = 4.dp),
                         text = stringResource(id = R.string.dialog_with_checkbox_reset)
                     )
                 }

+ 38 - 1
app/src/main/java/eu/kanade/tachiyomi/AppModule.kt

@@ -2,9 +2,18 @@ package eu.kanade.tachiyomi
 
 import android.app.Application
 import androidx.core.content.ContextCompat
+import com.squareup.sqldelight.android.AndroidSqliteDriver
+import com.squareup.sqldelight.db.SqlDriver
+import data.History
+import data.Mangas
+import eu.kanade.data.AndroidDatabaseHandler
+import eu.kanade.data.DatabaseHandler
+import eu.kanade.data.dateAdapter
+import eu.kanade.data.listOfStringsAdapter
 import eu.kanade.tachiyomi.data.cache.ChapterCache
 import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.DbOpenCallback
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.saver.ImageSaver
@@ -25,11 +34,37 @@ class AppModule(val app: Application) : InjektModule {
     override fun InjektRegistrar.registerInjectables() {
         addSingleton(app)
 
+        addSingletonFactory { DbOpenCallback() }
+
+        addSingletonFactory<SqlDriver> {
+            AndroidSqliteDriver(
+                schema = Database.Schema,
+                context = app,
+                name = DbOpenCallback.DATABASE_NAME,
+                callback = get<DbOpenCallback>()
+            )
+        }
+
+        addSingletonFactory {
+            Database(
+                driver = get(),
+                historyAdapter = History.Adapter(
+                    history_last_readAdapter = dateAdapter,
+                    history_time_readAdapter = dateAdapter
+                ),
+                mangasAdapter = Mangas.Adapter(
+                    genreAdapter = listOfStringsAdapter
+                )
+            )
+        }
+
+        addSingletonFactory<DatabaseHandler> { AndroidDatabaseHandler(get(), get()) }
+
         addSingletonFactory { Json { ignoreUnknownKeys = true } }
 
         addSingletonFactory { PreferencesHelper(app) }
 
-        addSingletonFactory { DatabaseHelper(app) }
+        addSingletonFactory { DatabaseHelper(app, get()) }
 
         addSingletonFactory { ChapterCache(app) }
 
@@ -57,6 +92,8 @@ class AppModule(val app: Application) : InjektModule {
 
             get<SourceManager>()
 
+            get<Database>()
+
             get<DatabaseHelper>()
 
             get<DownloadManager>()

+ 5 - 2
app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt

@@ -26,12 +26,15 @@ import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
 /**
  * This class provides operations to manage the database through its interfaces.
  */
-open class DatabaseHelper(context: Context) :
+open class DatabaseHelper(
+    context: Context,
+    callback: DbOpenCallback
+) :
     MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
 
     private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
         .name(DbOpenCallback.DATABASE_NAME)
-        .callback(DbOpenCallback())
+        .callback(callback)
         .build()
 
     override val db = DefaultStorIOSQLite.builder()

+ 10 - 80
app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt

@@ -2,98 +2,28 @@ package eu.kanade.tachiyomi.data.database
 
 import androidx.sqlite.db.SupportSQLiteDatabase
 import androidx.sqlite.db.SupportSQLiteOpenHelper
-import eu.kanade.tachiyomi.data.database.tables.CategoryTable
-import eu.kanade.tachiyomi.data.database.tables.ChapterTable
-import eu.kanade.tachiyomi.data.database.tables.HistoryTable
-import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
-import eu.kanade.tachiyomi.data.database.tables.MangaTable
-import eu.kanade.tachiyomi.data.database.tables.TrackTable
+import com.squareup.sqldelight.android.AndroidSqliteDriver
+import eu.kanade.tachiyomi.Database
 
-class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
+class DbOpenCallback : SupportSQLiteOpenHelper.Callback(Database.Schema.version) {
 
     companion object {
         /**
          * Name of the database file.
          */
         const val DATABASE_NAME = "tachiyomi.db"
-
-        /**
-         * Version of the database.
-         */
-        const val DATABASE_VERSION = 14
     }
 
-    override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
-        execSQL(MangaTable.createTableQuery)
-        execSQL(ChapterTable.createTableQuery)
-        execSQL(TrackTable.createTableQuery)
-        execSQL(CategoryTable.createTableQuery)
-        execSQL(MangaCategoryTable.createTableQuery)
-        execSQL(HistoryTable.createTableQuery)
-
-        // DB indexes
-        execSQL(MangaTable.createUrlIndexQuery)
-        execSQL(MangaTable.createLibraryIndexQuery)
-        execSQL(ChapterTable.createMangaIdIndexQuery)
-        execSQL(ChapterTable.createUnreadChaptersIndexQuery)
-        execSQL(HistoryTable.createChapterIdIndexQuery)
+    override fun onCreate(db: SupportSQLiteDatabase) {
+        Database.Schema.create(AndroidSqliteDriver(database = db, cacheSize = 1))
     }
 
     override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
-        if (oldVersion < 2) {
-            db.execSQL(ChapterTable.sourceOrderUpdateQuery)
-
-            // Fix kissmanga covers after supporting cloudflare
-            db.execSQL(
-                """UPDATE mangas SET thumbnail_url =
-                    REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""",
-            )
-        }
-        if (oldVersion < 3) {
-            // Initialize history tables
-            db.execSQL(HistoryTable.createTableQuery)
-            db.execSQL(HistoryTable.createChapterIdIndexQuery)
-        }
-        if (oldVersion < 4) {
-            db.execSQL(ChapterTable.bookmarkUpdateQuery)
-        }
-        if (oldVersion < 5) {
-            db.execSQL(ChapterTable.addScanlator)
-        }
-        if (oldVersion < 6) {
-            db.execSQL(TrackTable.addTrackingUrl)
-        }
-        if (oldVersion < 7) {
-            db.execSQL(TrackTable.addLibraryId)
-        }
-        if (oldVersion < 8) {
-            db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index")
-            db.execSQL(MangaTable.createLibraryIndexQuery)
-            db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
-        }
-        if (oldVersion < 9) {
-            db.execSQL(TrackTable.addStartDate)
-            db.execSQL(TrackTable.addFinishDate)
-        }
-        if (oldVersion < 10) {
-            db.execSQL(MangaTable.addCoverLastModified)
-        }
-        if (oldVersion < 11) {
-            db.execSQL(MangaTable.addDateAdded)
-            db.execSQL(MangaTable.backfillDateAdded)
-        }
-        if (oldVersion < 12) {
-            db.execSQL(MangaTable.addNextUpdateCol)
-        }
-        if (oldVersion < 13) {
-            db.execSQL(TrackTable.renameTableToTemp)
-            db.execSQL(TrackTable.createTableQuery)
-            db.execSQL(TrackTable.insertFromTempTable)
-            db.execSQL(TrackTable.dropTempTable)
-        }
-        if (oldVersion < 14) {
-            db.execSQL(ChapterTable.fixDateUploadIfNeeded)
-        }
+        Database.Schema.migrate(
+            driver = AndroidSqliteDriver(database = db, cacheSize = 1),
+            oldVersion = oldVersion,
+            newVersion = newVersion
+        )
     }
 
     override fun onConfigure(db: SupportSQLiteDatabase) {

+ 0 - 56
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt

@@ -4,39 +4,11 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
 import com.pushtorefresh.storio.sqlite.queries.RawQuery
 import eu.kanade.tachiyomi.data.database.DbProvider
 import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
 import eu.kanade.tachiyomi.data.database.resolvers.HistoryUpsertResolver
-import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver
 import eu.kanade.tachiyomi.data.database.tables.HistoryTable
-import java.util.Date
 
 interface HistoryQueries : DbProvider {
 
-    /**
-     * Insert history into database
-     * @param history object containing history information
-     */
-    fun insertHistory(history: History) = db.put().`object`(history).prepare()
-
-    /**
-     * Returns history of recent manga containing last read chapter
-     * @param date recent date range
-     * @param limit the limit of manga to grab
-     * @param offset offset the db by
-     * @param search what to search in the db history
-     */
-    fun getRecentManga(date: Date, limit: Int = 25, offset: Int = 0, search: String = "") = db.get()
-        .listOfObjects(MangaChapterHistory::class.java)
-        .withQuery(
-            RawQuery.builder()
-                .query(getRecentMangasQuery(search))
-                .args(date.time, limit, offset)
-                .observesTables(HistoryTable.TABLE)
-                .build(),
-        )
-        .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE)
-        .prepare()
-
     fun getHistoryByMangaId(mangaId: Long) = db.get()
         .listOfObjects(History::class.java)
         .withQuery(
@@ -79,34 +51,6 @@ interface HistoryQueries : DbProvider {
         .withPutResolver(HistoryUpsertResolver())
         .prepare()
 
-    fun resetHistoryLastRead(historyId: Long) = db.executeSQL()
-        .withQuery(
-            RawQuery.builder()
-                .query(
-                    """
-                UPDATE ${HistoryTable.TABLE} 
-                SET history_last_read = 0
-                WHERE ${HistoryTable.COL_ID} = $historyId  
-                    """.trimIndent()
-                )
-                .build()
-        )
-        .prepare()
-
-    fun resetHistoryLastRead(historyIds: List<Long>) = db.executeSQL()
-        .withQuery(
-            RawQuery.builder()
-                .query(
-                    """
-                UPDATE ${HistoryTable.TABLE} 
-                SET history_last_read = 0
-                WHERE ${HistoryTable.COL_ID} in ${historyIds.joinToString(",", "(", ")")}  
-                    """.trimIndent()
-                )
-                .build()
-        )
-        .prepare()
-
     fun dropHistoryTable() = db.delete()
         .byQuery(
             DeleteQuery.builder()

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt

@@ -70,7 +70,8 @@ fun getRecentMangasQuery(search: String = "") =
     SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID}, MAX(${History.TABLE}.${History.COL_LAST_READ}) as ${History.COL_LAST_READ}
     FROM ${Chapter.TABLE} JOIN ${History.TABLE}
     ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}
-    GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS max_last_read
+    GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
+    ) AS max_last_read
     ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = max_last_read.${Chapter.COL_MANGA_ID}
     WHERE ${History.TABLE}.${History.COL_LAST_READ} > ?
     AND max_last_read.${History.COL_CHAPTER_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}

+ 0 - 9
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt

@@ -11,13 +11,4 @@ object CategoryTable {
     const val COL_ORDER = "sort"
 
     const val COL_FLAGS = "flags"
-
-    val createTableQuery: String
-        get() =
-            """CREATE TABLE $TABLE(
-            $COL_ID INTEGER NOT NULL PRIMARY KEY,
-            $COL_NAME TEXT NOT NULL,
-            $COL_ORDER INTEGER NOT NULL,
-            $COL_FLAGS INTEGER NOT NULL
-            )"""
 }

+ 0 - 38
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt

@@ -27,42 +27,4 @@ object ChapterTable {
     const val COL_CHAPTER_NUMBER = "chapter_number"
 
     const val COL_SOURCE_ORDER = "source_order"
-
-    val createTableQuery: String
-        get() =
-            """CREATE TABLE $TABLE(
-            $COL_ID INTEGER NOT NULL PRIMARY KEY,
-            $COL_MANGA_ID INTEGER NOT NULL,
-            $COL_URL TEXT NOT NULL,
-            $COL_NAME TEXT NOT NULL,
-            $COL_SCANLATOR TEXT,
-            $COL_READ BOOLEAN NOT NULL,
-            $COL_BOOKMARK BOOLEAN NOT NULL,
-            $COL_LAST_PAGE_READ INT NOT NULL,
-            $COL_CHAPTER_NUMBER FLOAT NOT NULL,
-            $COL_SOURCE_ORDER INTEGER NOT NULL,
-            $COL_DATE_FETCH LONG NOT NULL,
-            $COL_DATE_UPLOAD LONG NOT NULL,
-            FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
-            ON DELETE CASCADE
-            )"""
-
-    val createMangaIdIndexQuery: String
-        get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
-
-    val createUnreadChaptersIndexQuery: String
-        get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " +
-            "WHERE $COL_READ = 0"
-
-    val sourceOrderUpdateQuery: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
-
-    val bookmarkUpdateQuery: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_BOOKMARK BOOLEAN DEFAULT FALSE"
-
-    val addScanlator: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SCANLATOR TEXT DEFAULT NULL"
-
-    val fixDateUploadIfNeeded: String
-        get() = "UPDATE $TABLE SET $COL_DATE_UPLOAD = $COL_DATE_FETCH WHERE $COL_DATE_UPLOAD = 0"
 }

+ 0 - 20
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt

@@ -26,24 +26,4 @@ object HistoryTable {
      * Time read column name
      */
     const val COL_TIME_READ = "${TABLE}_time_read"
-
-    /**
-     * query to create history table
-     */
-    val createTableQuery: String
-        get() =
-            """CREATE TABLE $TABLE(
-            $COL_ID INTEGER NOT NULL PRIMARY KEY,
-            $COL_CHAPTER_ID INTEGER NOT NULL UNIQUE,
-            $COL_LAST_READ LONG,
-            $COL_TIME_READ LONG,
-            FOREIGN KEY($COL_CHAPTER_ID) REFERENCES ${ChapterTable.TABLE} (${ChapterTable.COL_ID})
-            ON DELETE CASCADE
-            )"""
-
-    /**
-     * query to index history chapter id
-     */
-    val createChapterIdIndexQuery: String
-        get() = "CREATE INDEX ${TABLE}_${COL_CHAPTER_ID}_index ON $TABLE($COL_CHAPTER_ID)"
 }

+ 0 - 12
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt

@@ -9,16 +9,4 @@ object MangaCategoryTable {
     const val COL_MANGA_ID = "manga_id"
 
     const val COL_CATEGORY_ID = "category_id"
-
-    val createTableQuery: String
-        get() =
-            """CREATE TABLE $TABLE(
-            $COL_ID INTEGER NOT NULL PRIMARY KEY,
-            $COL_MANGA_ID INTEGER NOT NULL,
-            $COL_CATEGORY_ID INTEGER NOT NULL,
-            FOREIGN KEY($COL_CATEGORY_ID) REFERENCES ${CategoryTable.TABLE} (${CategoryTable.COL_ID})
-            ON DELETE CASCADE,
-            FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
-            ON DELETE CASCADE
-            )"""
 }

+ 0 - 49
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt

@@ -47,53 +47,4 @@ object MangaTable {
     const val COMPUTED_COL_UNREAD_COUNT = "unread_count"
 
     const val COMPUTED_COL_READ_COUNT = "read_count"
-
-    val createTableQuery: String
-        get() =
-            """CREATE TABLE $TABLE(
-            $COL_ID INTEGER NOT NULL PRIMARY KEY,
-            $COL_SOURCE INTEGER NOT NULL,
-            $COL_URL TEXT NOT NULL,
-            $COL_ARTIST TEXT,
-            $COL_AUTHOR TEXT,
-            $COL_DESCRIPTION TEXT,
-            $COL_GENRE TEXT,
-            $COL_TITLE TEXT NOT NULL,
-            $COL_STATUS INTEGER NOT NULL,
-            $COL_THUMBNAIL_URL TEXT,
-            $COL_FAVORITE INTEGER NOT NULL,
-            $COL_LAST_UPDATE LONG,
-            $COL_NEXT_UPDATE LONG,
-            $COL_INITIALIZED BOOLEAN NOT NULL,
-            $COL_VIEWER INTEGER NOT NULL,
-            $COL_CHAPTER_FLAGS INTEGER NOT NULL,
-            $COL_COVER_LAST_MODIFIED LONG NOT NULL,
-            $COL_DATE_ADDED LONG NOT NULL
-            )"""
-
-    val createUrlIndexQuery: String
-        get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
-
-    val createLibraryIndexQuery: String
-        get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
-            "WHERE $COL_FAVORITE = 1"
-
-    val addCoverLastModified: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_COVER_LAST_MODIFIED LONG NOT NULL DEFAULT 0"
-
-    val addDateAdded: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_DATE_ADDED LONG NOT NULL DEFAULT 0"
-
-    /**
-     * Used with addDateAdded to populate it with the oldest chapter fetch date.
-     */
-    val backfillDateAdded: String
-        get() = "UPDATE $TABLE SET $COL_DATE_ADDED = " +
-            "(SELECT MIN(${ChapterTable.COL_DATE_FETCH}) " +
-            "FROM $TABLE INNER JOIN ${ChapterTable.TABLE} " +
-            "ON $TABLE.$COL_ID = ${ChapterTable.TABLE}.${ChapterTable.COL_MANGA_ID} " +
-            "GROUP BY $TABLE.$COL_ID)"
-
-    val addNextUpdateCol: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_NEXT_UPDATE LONG DEFAULT 0"
 }

+ 0 - 40
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt

@@ -30,43 +30,6 @@ object TrackTable {
 
     const val COL_FINISH_DATE = "finish_date"
 
-    val createTableQuery: String
-        get() =
-            """CREATE TABLE $TABLE(
-            $COL_ID INTEGER NOT NULL PRIMARY KEY,
-            $COL_MANGA_ID INTEGER NOT NULL,
-            $COL_SYNC_ID INTEGER NOT NULL,
-            $COL_MEDIA_ID INTEGER NOT NULL,
-            $COL_LIBRARY_ID INTEGER,
-            $COL_TITLE TEXT NOT NULL,
-            $COL_LAST_CHAPTER_READ REAL NOT NULL,
-            $COL_TOTAL_CHAPTERS INTEGER NOT NULL,
-            $COL_STATUS INTEGER NOT NULL,
-            $COL_SCORE FLOAT NOT NULL,
-            $COL_TRACKING_URL TEXT NOT NULL,
-            $COL_START_DATE LONG NOT NULL,
-            $COL_FINISH_DATE LONG NOT NULL,
-            UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
-            FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
-            ON DELETE CASCADE
-            )"""
-
-    val addTrackingUrl: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''"
-
-    val addLibraryId: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL"
-
-    val addStartDate: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_START_DATE LONG NOT NULL DEFAULT 0"
-
-    val addFinishDate: String
-        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0"
-
-    val renameTableToTemp: String
-        get() =
-            "ALTER TABLE $TABLE RENAME TO ${TABLE}_tmp"
-
     val insertFromTempTable: String
         get() =
             """
@@ -74,7 +37,4 @@ object TrackTable {
             |SELECT $COL_ID,$COL_MANGA_ID,$COL_SYNC_ID,$COL_MEDIA_ID,$COL_LIBRARY_ID,$COL_TITLE,$COL_LAST_CHAPTER_READ,$COL_TOTAL_CHAPTERS,$COL_STATUS,$COL_SCORE,$COL_TRACKING_URL,$COL_START_DATE,$COL_FINISH_DATE
             |FROM ${TABLE}_tmp
             """.trimMargin()
-
-    val dropTempTable: String
-        get() = "DROP TABLE ${TABLE}_tmp"
 }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt

@@ -166,7 +166,7 @@ class NotificationReceiver : BroadcastReceiver() {
      * @param chapterId id of chapter
      */
     private fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
-        val db = DatabaseHelper(context)
+        val db = Injekt.get<DatabaseHelper>()
         val manga = db.getManga(mangaId).executeAsBlocking()
         val chapter = db.getChapter(chapterId).executeAsBlocking()
         if (manga != null && chapter != null) {

+ 3 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -35,6 +35,7 @@ import com.google.android.material.snackbar.Snackbar
 import dev.chrisbanes.insetter.applyInsetter
 import eu.davidea.flexibleadapter.FlexibleAdapter
 import eu.davidea.flexibleadapter.SelectableAdapter
+import eu.kanade.domain.history.model.HistoryWithRelations
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
@@ -118,6 +119,8 @@ class MangaController :
     DownloadCustomChaptersDialog.Listener,
     DeleteChaptersDialog.Listener {
 
+    constructor(history: HistoryWithRelations) : this(history.mangaId)
+
     constructor(manga: Manga?, fromSource: Boolean = false) : super(
         bundleOf(
             MANGA_EXTRA to (manga?.id ?: 0),

+ 8 - 8
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt

@@ -6,9 +6,9 @@ import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
 import androidx.appcompat.widget.SearchView
+import eu.kanade.domain.chapter.model.Chapter
 import eu.kanade.presentation.history.HistoryScreen
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
 import eu.kanade.tachiyomi.ui.base.controller.NucleusController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
@@ -44,16 +44,16 @@ class HistoryController :
             HistoryScreen(
                 composeView = binding.root,
                 presenter = presenter,
-                onClickItem = { (manga, _, _) ->
-                    router.pushController(MangaController(manga).withFadeTransaction())
+                onClickItem = { history ->
+                    router.pushController(MangaController(history).withFadeTransaction())
                 },
-                onClickResume = { (manga, chapter, _) ->
-                    presenter.getNextChapterForManga(manga, chapter)
+                onClickResume = { history ->
+                    presenter.getNextChapterForManga(history.mangaId, history.chapterId)
                 },
-                onClickDelete = { (manga, _, history), all ->
+                onClickDelete = { history, all ->
                     if (all) {
                         // Reset last read of chapter to 0L
-                        presenter.removeAllFromHistory(manga.id!!)
+                        presenter.removeAllFromHistory(history.mangaId)
                     } else {
                         // Remove all chapters belonging to manga from library
                         presenter.removeFromHistory(history)
@@ -97,7 +97,7 @@ class HistoryController :
     fun openChapter(chapter: Chapter?) {
         val activity = activity ?: return
         if (chapter != null) {
-            val intent = ReaderActivity.newIntent(activity, chapter.manga_id, chapter.id)
+            val intent = ReaderActivity.newIntent(activity, chapter.mangaId, chapter.id)
             startActivity(intent)
         } else {
             activity.toast(R.string.no_next_chapter)

+ 9 - 19
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt

@@ -10,11 +10,8 @@ import eu.kanade.domain.history.interactor.GetHistory
 import eu.kanade.domain.history.interactor.GetNextChapterForManga
 import eu.kanade.domain.history.interactor.RemoveHistoryById
 import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
+import eu.kanade.domain.history.model.HistoryWithRelations
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.Chapter
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.launchUI
@@ -58,20 +55,13 @@ class HistoryPresenter(
                             .map { pagingData ->
                                 pagingData
                                     .map {
-                                        UiModel.History(it)
+                                        UiModel.Item(it)
                                     }
                                     .insertSeparators { before, after ->
-                                        val beforeDate =
-                                            before?.item?.history?.last_read?.toDateKey()
-                                        val afterDate =
-                                            after?.item?.history?.last_read?.toDateKey()
+                                        val beforeDate = before?.item?.readAt?.time?.toDateKey() ?: Date(0)
+                                        val afterDate = after?.item?.readAt?.time?.toDateKey() ?: Date(0)
                                         when {
-                                            beforeDate == null && afterDate != null -> UiModel.Header(
-                                                afterDate,
-                                            )
-                                            beforeDate != null && afterDate != null -> UiModel.Header(
-                                                afterDate,
-                                            )
+                                            beforeDate.time != afterDate.time && afterDate.time != 0L -> UiModel.Header(afterDate)
                                             // Return null to avoid adding a separator between two items.
                                             else -> null
                                         }
@@ -90,7 +80,7 @@ class HistoryPresenter(
         }
     }
 
-    fun removeFromHistory(history: History) {
+    fun removeFromHistory(history: HistoryWithRelations) {
         presenterScope.launchIO {
             removeHistoryById.await(history)
         }
@@ -102,9 +92,9 @@ class HistoryPresenter(
         }
     }
 
-    fun getNextChapterForManga(manga: Manga, chapter: Chapter) {
+    fun getNextChapterForManga(mangaId: Long, chapterId: Long) {
         presenterScope.launchIO {
-            val chapter = getNextChapterForManga.await(manga, chapter)
+            val chapter = getNextChapterForManga.await(mangaId, chapterId)
             view?.openChapter(chapter)
         }
     }
@@ -121,7 +111,7 @@ class HistoryPresenter(
 }
 
 sealed class UiModel {
-    data class History(val item: MangaChapterHistory) : UiModel()
+    data class Item(val item: HistoryWithRelations) : UiModel()
     data class Header(val date: Date) : UiModel()
 }
 

+ 3 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.ui.setting.database
 
 import android.os.Bundle
+import eu.kanade.tachiyomi.Database
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
@@ -13,6 +14,7 @@ import uy.kohesive.injekt.api.get
 class ClearDatabasePresenter : BasePresenter<ClearDatabaseController>() {
 
     private val db = Injekt.get<DatabaseHelper>()
+    private val database = Injekt.get<Database>()
 
     private val sourceManager = Injekt.get<SourceManager>()
 
@@ -26,7 +28,7 @@ class ClearDatabasePresenter : BasePresenter<ClearDatabaseController>() {
 
     fun clearDatabaseForSourceIds(sources: List<Long>) {
         db.deleteMangasNotInLibraryBySourceIds(sources).executeAsBlocking()
-        db.deleteHistoryNoLastRead().executeAsBlocking()
+        database.historyQueries.removeResettedHistory()
     }
 
     private fun getDatabaseSourcesObservable(): Observable<List<ClearDatabaseSourceItem>> {

+ 6 - 0
app/src/main/sqldelight/data/categories.sq

@@ -0,0 +1,6 @@
+CREATE TABLE categories(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    name TEXT NOT NULL,
+    sort INTEGER NOT NULL,
+    flags INTEGER NOT NULL
+);

+ 26 - 0
app/src/main/sqldelight/data/chapters.sq

@@ -0,0 +1,26 @@
+CREATE TABLE chapters(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    manga_id INTEGER NOT NULL,
+    url TEXT NOT NULL,
+    name TEXT NOT NULL,
+    scanlator TEXT,
+    read INTEGER AS Boolean NOT NULL,
+    bookmark INTEGER AS Boolean NOT NULL,
+    last_page_read INTEGER NOT NULL,
+    chapter_number REAL AS Float NOT NULL,
+    source_order INTEGER NOT NULL,
+    date_fetch INTEGER AS Long NOT NULL,
+    date_upload INTEGER AS Long NOT NULL,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);
+
+getChapterById:
+SELECT *
+FROM chapters
+WHERE _id = :id;
+
+getChapterByMangaId:
+SELECT *
+FROM chapters
+WHERE manga_id = :mangaId;

+ 35 - 0
app/src/main/sqldelight/data/history.sq

@@ -0,0 +1,35 @@
+import java.util.Date;
+
+CREATE TABLE history(
+    history_id INTEGER NOT NULL PRIMARY KEY,
+    history_chapter_id INTEGER NOT NULL UNIQUE,
+    history_last_read INTEGER AS Date,
+    history_time_read INTEGER AS Date,
+    FOREIGN KEY(history_chapter_id) REFERENCES chapters (_id)
+    ON DELETE CASCADE
+);
+
+resetHistoryById:
+UPDATE history
+SET history_last_read = 0
+WHERE history_id = :historyId;
+
+resetHistoryByMangaId:
+UPDATE history
+SET history_last_read = 0
+WHERE history_id IN (
+    SELECT H.history_id
+    FROM mangas M
+    INNER JOIN chapters C
+    ON M._id = C.manga_id
+    INNER JOIN history H
+    ON C._id = H.history_chapter_id
+    WHERE M._id = :mangaId
+);
+
+removeAllHistory:
+DELETE FROM history;
+
+removeResettedHistory:
+DELETE FROM history
+WHERE history_last_read = 0;

+ 18 - 0
app/src/main/sqldelight/data/manga_sync.sq

@@ -0,0 +1,18 @@
+CREATE TABLE manga_sync(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    manga_id INTEGER NOT NULL,
+    sync_id INTEGER NOT NULL,
+    remote_id INTEGER NOT NULL,
+    library_id INTEGER,
+    title TEXT NOT NULL,
+    last_chapter_read REAL NOT NULL,
+    total_chapters INTEGER NOT NULL,
+    status INTEGER NOT NULL,
+    score REAL AS Float NOT NULL,
+    remote_url TEXT NOT NULL,
+    start_date INTEGER AS Long NOT NULL,
+    finish_date INTEGER AS Long NOT NULL,
+    UNIQUE (manga_id, sync_id) ON CONFLICT REPLACE,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);

+ 28 - 0
app/src/main/sqldelight/data/mangas.sq

@@ -0,0 +1,28 @@
+import java.lang.String;
+import kotlin.collections.List;
+
+CREATE TABLE mangas(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    source INTEGER NOT NULL,
+    url TEXT NOT NULL,
+    artist TEXT,
+    author TEXT,
+    description TEXT,
+    genre TEXT AS List<String>,
+    title TEXT NOT NULL,
+    status INTEGER NOT NULL,
+    thumbnail_url TEXT,
+    favorite INTEGER AS Boolean NOT NULL,
+    last_update INTEGER AS Long,
+    next_update INTEGER AS Long,
+    initialized INTEGER AS Boolean NOT NULL,
+    viewer INTEGER NOT NULL,
+    chapter_flags INTEGER NOT NULL,
+    cover_last_modified INTEGER AS Long NOT NULL,
+    date_added INTEGER AS Long NOT NULL
+);
+
+getMangaById:
+SELECT *
+FROM mangas
+WHERE _id = :id;

+ 9 - 0
app/src/main/sqldelight/data/mangas_categories.sq

@@ -0,0 +1,9 @@
+CREATE TABLE mangas_categories(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    manga_id INTEGER NOT NULL,
+    category_id INTEGER NOT NULL,
+    FOREIGN KEY(category_id) REFERENCES categories (_id)
+    ON DELETE CASCADE,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);

+ 6 - 0
app/src/main/sqldelight/migrations/1.sqm

@@ -0,0 +1,6 @@
+ALTER TABLE chapters
+ADD COLUMN source_order INTEGER DEFAULT 0;
+
+UPDATE mangas
+SET thumbnail_url = replace(thumbnail_url, '93.174.95.110', 'kissmanga.com')
+WHERE source = 4;

+ 11 - 0
app/src/main/sqldelight/migrations/10.sqm

@@ -0,0 +1,11 @@
+ALTER TABLE mangas
+ADD COLUMN date_added INTEGER NOT NULL DEFAULT 0;
+
+UPDATE mangas
+SET date_added = (
+    SELECT MIN(date_fetch)
+    FROM mangas M
+    INNER JOIN chapters C
+    ON M._id = C.manga_id
+    GROUP BY M._id
+);

+ 2 - 0
app/src/main/sqldelight/migrations/11.sqm

@@ -0,0 +1,2 @@
+ALTER TABLE mangas
+ADD COLUMN next_update INTEGER DEFAULT 0;

+ 9 - 0
app/src/main/sqldelight/migrations/12.sqm

@@ -0,0 +1,9 @@
+ALTER TABLE manga_sync
+RENAME TO manga_sync_tmp;
+
+INSERT INTO manga_sync(_id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score, remote_url, start_date, finish_date)
+SELECT _id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score, remote_url, start_date, finish_date
+FROM manga_sync_tmp;
+
+
+DROP TABLE manga_sync_tmp;

+ 3 - 0
app/src/main/sqldelight/migrations/13.sqm

@@ -0,0 +1,3 @@
+UPDATE chapters
+SET date_upload = date_fetch
+WHERE date_upload = 0;

+ 149 - 0
app/src/main/sqldelight/migrations/14.sqm

@@ -0,0 +1,149 @@
+DROP INDEX IF EXISTS chapters_manga_id_index;
+DROP INDEX IF EXISTS chapters_unread_by_manga_index;
+DROP INDEX IF EXISTS history_history_chapter_id_index;
+DROP INDEX IF EXISTS library_favorite_index;
+DROP INDEX IF EXISTS mangas_url_index;
+
+ALTER TABLE mangas RENAME TO manga_temp;
+CREATE TABLE mangas(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    source INTEGER NOT NULL,
+    url TEXT NOT NULL,
+    artist TEXT,
+    author TEXT,
+    description TEXT,
+    genre TEXT,
+    title TEXT NOT NULL,
+    status INTEGER NOT NULL,
+    thumbnail_url TEXT,
+    favorite INTEGER NOT NULL,
+    last_update INTEGER AS Long,
+    next_update INTEGER AS Long,
+    initialized INTEGER AS Boolean NOT NULL,
+    viewer INTEGER NOT NULL,
+    chapter_flags INTEGER NOT NULL,
+    cover_last_modified INTEGER AS Long NOT NULL,
+    date_added INTEGER AS Long NOT NULL
+);
+INSERT INTO mangas
+SELECT _id,source,url,artist,author,description,genre,title,status,thumbnail_url,favorite,last_update,next_update,initialized,viewer,chapter_flags,cover_last_modified,date_added
+FROM manga_temp;
+
+ALTER TABLE categories RENAME TO categories_temp;
+CREATE TABLE categories(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    name TEXT NOT NULL,
+    sort INTEGER NOT NULL,
+    flags INTEGER NOT NULL
+);
+INSERT INTO categories
+SELECT _id,name,sort,flags
+FROM categories_temp;
+
+ALTER TABLE chapters RENAME TO chapters_temp;
+CREATE TABLE chapters(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    manga_id INTEGER NOT NULL,
+    url TEXT NOT NULL,
+    name TEXT NOT NULL,
+    scanlator TEXT,
+    read INTEGER AS Boolean NOT NULL,
+    bookmark INTEGER AS Boolean NOT NULL,
+    last_page_read INTEGER NOT NULL,
+    chapter_number REAL AS Float NOT NULL,
+    source_order INTEGER NOT NULL,
+    date_fetch INTEGER AS Long NOT NULL,
+    date_upload INTEGER AS Long NOT NULL,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);
+INSERT INTO chapters
+SELECT _id,manga_id,url,name,scanlator,read,bookmark,last_page_read,chapter_number,source_order,date_fetch,date_upload
+FROM chapters_temp;
+
+ALTER TABLE history RENAME TO history_temp;
+CREATE TABLE history(
+    history_id INTEGER NOT NULL PRIMARY KEY,
+    history_chapter_id INTEGER NOT NULL UNIQUE,
+    history_last_read INTEGER AS Long,
+    history_time_read INTEGER AS Long,
+    FOREIGN KEY(history_chapter_id) REFERENCES chapters (_id)
+    ON DELETE CASCADE
+);
+INSERT INTO history
+SELECT history_id, history_chapter_id, history_last_read, history_time_read
+FROM history_temp;
+
+ALTER TABLE mangas_categories RENAME TO mangas_categories_temp;
+CREATE TABLE mangas_categories(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    manga_id INTEGER NOT NULL,
+    category_id INTEGER NOT NULL,
+    FOREIGN KEY(category_id) REFERENCES categories (_id)
+    ON DELETE CASCADE,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);
+INSERT INTO mangas_categories
+SELECT _id, manga_id, category_id
+FROM mangas_categories_temp;
+
+ALTER TABLE manga_sync RENAME TO manga_sync_temp;
+CREATE TABLE manga_sync(
+    _id INTEGER NOT NULL PRIMARY KEY,
+    manga_id INTEGER NOT NULL,
+    sync_id INTEGER NOT NULL,
+    remote_id INTEGER NOT NULL,
+    library_id INTEGER,
+    title TEXT NOT NULL,
+    last_chapter_read REAL NOT NULL,
+    total_chapters INTEGER NOT NULL,
+    status INTEGER NOT NULL,
+    score REAL AS Float NOT NULL,
+    remote_url TEXT NOT NULL,
+    start_date INTEGER AS Long NOT NULL,
+    finish_date INTEGER AS Long NOT NULL,
+    UNIQUE (manga_id, sync_id) ON CONFLICT REPLACE,
+    FOREIGN KEY(manga_id) REFERENCES mangas (_id)
+    ON DELETE CASCADE
+);
+INSERT INTO manga_sync
+SELECT _id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score, remote_url, start_date, finish_date
+FROM manga_sync_temp;
+
+CREATE INDEX chapters_manga_id_index ON chapters(manga_id);
+CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0;
+CREATE INDEX history_history_chapter_id_index ON history(history_chapter_id);
+CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1;
+CREATE INDEX mangas_url_index ON mangas(url);
+
+CREATE VIEW IF NOT EXISTS historyView AS
+SELECT
+history.history_id AS id,
+mangas._id AS mangaId,
+chapters._id AS chapterId,
+mangas.title,
+mangas.thumbnail_url AS thumnailUrl,
+chapters.chapter_number AS chapterNumber,
+history.history_last_read AS readAt,
+max_last_read.history_last_read AS maxReadAt,
+max_last_read.history_chapter_id AS maxReadAtChapterId
+FROM mangas
+JOIN chapters
+ON mangas._id = chapters.manga_id
+JOIN history
+ON chapters._id = history.history_chapter_id
+JOIN (
+SELECT chapters.manga_id,chapters._id AS history_chapter_id, MAX(history.history_last_read) AS history_last_read
+FROM chapters JOIN history
+ON chapters._id = history.history_chapter_id
+GROUP BY chapters.manga_id
+) AS max_last_read
+ON chapters.manga_id = max_last_read.manga_id;
+
+DROP TABLE IF EXISTS manga_sync_temp;
+DROP TABLE IF EXISTS mangas_categories_temp;
+DROP TABLE IF EXISTS history_temp;
+DROP TABLE IF EXISTS chapters_temp;
+DROP TABLE IF EXISTS categories_temp;
+DROP TABLE IF EXISTS manga_temp;

+ 10 - 0
app/src/main/sqldelight/migrations/2.sqm

@@ -0,0 +1,10 @@
+CREATE TABLE history(
+    history_id INTEGER NOT NULL PRIMARY KEY,
+    history_chapter_id INTEGER NOT NULL UNIQUE,
+    history_last_read INTEGER,
+    history_time_read INTEGER,
+    FOREIGN KEY(history_chapter_id) REFERENCES chapters (_id)
+    ON DELETE CASCADE
+);
+
+CREATE INDEX history_history_chapter_id_index ON history(history_chapter_id);

+ 2 - 0
app/src/main/sqldelight/migrations/3.sqm

@@ -0,0 +1,2 @@
+ALTER TABLE chapters
+ADD COLUMN bookmark INTEGER DEFAULT 0;

+ 2 - 0
app/src/main/sqldelight/migrations/4.sqm

@@ -0,0 +1,2 @@
+ALTER TABLE chapters
+ADD COLUMN scanlator TEXT DEFAULT NULL;

+ 2 - 0
app/src/main/sqldelight/migrations/5.sqm

@@ -0,0 +1,2 @@
+ALTER TABLE manga_sync
+ADD COLUMN remote_url TEXT DEFAULT '';

+ 2 - 0
app/src/main/sqldelight/migrations/6.sqm

@@ -0,0 +1,2 @@
+ALTER TABLE manga_sync
+ADD COLUMN library_id INTEGER;

+ 9 - 0
app/src/main/sqldelight/migrations/7.sqm

@@ -0,0 +1,9 @@
+DROP INDEX IF EXISTS mangas_favorite_index;
+
+CREATE INDEX library_favorite_index
+ON mangas(favorite)
+WHERE favorite = 1;
+
+CREATE INDEX chapters_unread_by_manga_index
+ON chapters(manga_id, read)
+WHERE read = 0;

+ 5 - 0
app/src/main/sqldelight/migrations/8.sqm

@@ -0,0 +1,5 @@
+ALTER TABLE manga_sync
+ADD COLUMN start_date INTEGER NOT NULL DEFAULT 0;
+
+ALTER TABLE manga_sync
+ADD COLUMN finish_date INTEGER NOT NULL DEFAULT 0;

+ 2 - 0
app/src/main/sqldelight/migrations/9.sqm

@@ -0,0 +1,2 @@
+ALTER TABLE mangas
+ADD COLUMN cover_last_modified INTEGER NOT NULL DEFAULT 0;

+ 46 - 0
app/src/main/sqldelight/view/historyView.sq

@@ -0,0 +1,46 @@
+CREATE VIEW historyView AS
+SELECT
+history.history_id AS id,
+mangas._id AS mangaId,
+chapters._id AS chapterId,
+mangas.title,
+mangas.thumbnail_url AS thumnailUrl,
+chapters.chapter_number AS chapterNumber,
+history.history_last_read AS readAt,
+max_last_read.history_last_read AS maxReadAt,
+max_last_read.history_chapter_id AS maxReadAtChapterId
+FROM mangas
+JOIN chapters
+ON mangas._id = chapters.manga_id
+JOIN history
+ON chapters._id = history.history_chapter_id
+JOIN (
+SELECT chapters.manga_id,chapters._id AS history_chapter_id, MAX(history.history_last_read) AS history_last_read
+FROM chapters JOIN history
+ON chapters._id = history.history_chapter_id
+GROUP BY chapters.manga_id
+) AS max_last_read
+ON chapters.manga_id = max_last_read.manga_id;
+
+countHistory:
+SELECT count(*)
+FROM historyView
+WHERE historyView.readAt > 0
+AND maxReadAtChapterId = historyView.chapterId
+AND lower(historyView.title) LIKE ('%' || :query || '%');
+
+history:
+SELECT
+id,
+mangaId,
+chapterId,
+title,
+thumnailUrl,
+chapterNumber,
+readAt
+FROM historyView
+WHERE historyView.readAt > 0
+AND maxReadAtChapterId = historyView.chapterId
+AND lower(historyView.title) LIKE ('%' || :query || '%')
+ORDER BY readAt DESC
+LIMIT :limit OFFSET :offset;

+ 1 - 0
build.gradle.kts

@@ -4,6 +4,7 @@ buildscript {
         classpath(libs.google.services.gradle)
         classpath(libs.aboutlibraries.gradle)
         classpath(kotlinx.serialization.gradle)
+        classpath("com.squareup.sqldelight:gradle-plugin:1.5.3")
     }
 }
 

+ 5 - 0
gradle/libs.versions.toml

@@ -7,6 +7,7 @@ conductor_version = "3.1.2"
 flowbinding_version = "1.2.0"
 shizuku_version = "12.1.0"
 robolectric_version = "3.1.4"
+sqldelight = "1.5.3"
 
 [libraries]
 android-shortcut-gradle = "com.github.zellius:android-shortcut-gradle-plugin:0.1.2"
@@ -97,6 +98,10 @@ robolectric-playservices = { module = "org.robolectric:shadows-play-services", v
 
 leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.7"
 
+sqldelight-android-driver = { module = "com.squareup.sqldelight:android-driver", version.ref ="sqldelight" }
+sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions-jvm", version.ref ="sqldelight" }
+sqldelight-android-paging = { module = "com.squareup.sqldelight:android-paging3-extensions", version.ref ="sqldelight" }
+
 [bundles]
 reactivex = ["rxandroid","rxjava","rxrelay"]
 okhttp = ["okhttp-core","okhttp-logging","okhttp-dnsoverhttps"]