Browse Source

Revert "Revert history Compose/SQLDelight changes"

This reverts commit 96c894ce5b3be545f3ea7df057d6dfed9fca3696.
arkon 3 năm trước cách đây
mục cha
commit
2b79295240
89 tập tin đã thay đổi với 1804 bổ sung986 xóa
  1. 22 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. 26 0
      app/src/main/java/eu/kanade/data/manga/MangaMapper.kt
  10. 26 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  11. 16 0
      app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt
  12. 12 0
      app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt
  13. 21 0
      app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt
  14. 13 0
      app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt
  15. 13 0
      app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt
  16. 12 0
      app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt
  17. 9 0
      app/src/main/java/eu/kanade/domain/history/model/History.kt
  18. 13 0
      app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt
  19. 18 0
      app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt
  20. 36 0
      app/src/main/java/eu/kanade/domain/manga/model/Manga.kt
  21. 49 0
      app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt
  22. 39 0
      app/src/main/java/eu/kanade/presentation/components/MangaCover.kt
  23. 297 0
      app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt
  24. 20 0
      app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt
  25. 5 0
      app/src/main/java/eu/kanade/presentation/util/Constants.kt
  26. 5 0
      app/src/main/java/eu/kanade/presentation/util/LazyListState.kt
  27. 2 0
      app/src/main/java/eu/kanade/tachiyomi/App.kt
  28. 38 1
      app/src/main/java/eu/kanade/tachiyomi/AppModule.kt
  29. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt
  30. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt
  31. 5 2
      app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt
  32. 10 80
      app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt
  33. 6 34
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt
  34. 2 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt
  35. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryUpsertResolver.kt
  36. 0 9
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt
  37. 0 38
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt
  38. 0 20
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt
  39. 0 12
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt
  40. 0 49
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt
  41. 0 40
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt
  42. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt
  43. 26 0
      app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt
  44. 3 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  45. 0 24
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaCoverImageView.kt
  46. 8 3
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
  47. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
  48. 21 0
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/ClearHistoryDialogController.kt
  49. 0 51
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt
  50. 44 197
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt
  51. 0 71
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt
  52. 0 42
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt
  53. 94 124
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt
  54. 0 54
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt
  55. 0 3
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt
  56. 3 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt
  57. 12 1
      app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt
  58. 1 1
      app/src/main/java/eu/kanade/tachiyomi/widget/preference/ThemesPreferenceAdapter.kt
  59. 4 0
      app/src/main/res/layout/compose_controller.xml
  60. 0 33
      app/src/main/res/layout/history_controller.xml
  61. 0 85
      app/src/main/res/layout/history_item.xml
  62. 6 0
      app/src/main/sqldelight/data/categories.sq
  63. 29 0
      app/src/main/sqldelight/data/chapters.sq
  64. 37 0
      app/src/main/sqldelight/data/history.sq
  65. 18 0
      app/src/main/sqldelight/data/manga_sync.sq
  66. 31 0
      app/src/main/sqldelight/data/mangas.sq
  67. 9 0
      app/src/main/sqldelight/data/mangas_categories.sq
  68. 6 0
      app/src/main/sqldelight/migrations/1.sqm
  69. 11 0
      app/src/main/sqldelight/migrations/10.sqm
  70. 2 0
      app/src/main/sqldelight/migrations/11.sqm
  71. 9 0
      app/src/main/sqldelight/migrations/12.sqm
  72. 3 0
      app/src/main/sqldelight/migrations/13.sqm
  73. 149 0
      app/src/main/sqldelight/migrations/14.sqm
  74. 10 0
      app/src/main/sqldelight/migrations/2.sqm
  75. 2 0
      app/src/main/sqldelight/migrations/3.sqm
  76. 2 0
      app/src/main/sqldelight/migrations/4.sqm
  77. 2 0
      app/src/main/sqldelight/migrations/5.sqm
  78. 2 0
      app/src/main/sqldelight/migrations/6.sqm
  79. 9 0
      app/src/main/sqldelight/migrations/7.sqm
  80. 5 0
      app/src/main/sqldelight/migrations/8.sqm
  81. 2 0
      app/src/main/sqldelight/migrations/9.sqm
  82. 46 0
      app/src/main/sqldelight/view/historyView.sq
  83. 1 1
      app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt
  84. 1 0
      build.gradle.kts
  85. 3 0
      gradle/androidx.versions.toml
  86. 9 0
      gradle/compose.versions.toml
  87. 1 1
      gradle/kotlinx.versions.toml
  88. 9 3
      gradle/libs.versions.toml
  89. 3 0
      settings.gradle.kts

+ 22 - 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")) {
@@ -109,6 +110,7 @@ android {
 
     buildFeatures {
         viewBinding = true
+        compose = true
 
         // Disable some unused things
         aidl = false
@@ -122,6 +124,10 @@ android {
         checkReleaseBuilds = false
     }
 
+    composeOptions {
+        kotlinCompilerExtensionVersion = compose.versions.compose.get()
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_1_8
         targetCompatibility = JavaVersion.VERSION_1_8
@@ -133,6 +139,19 @@ android {
 }
 
 dependencies {
+    implementation(compose.foundation)
+    implementation(compose.material3.core)
+    implementation(compose.material3.adapter)
+    implementation(compose.animation)
+    implementation(compose.ui.tooling)
+
+    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)
 
     implementation(kotlinx.bundles.coroutines)
@@ -263,6 +282,9 @@ tasks {
             "-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
             "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
             "-Xopt-in=coil.annotation.ExperimentalCoilApi",
+            "-Xopt-in=androidx.compose.material3.ExperimentalMaterial3Api",
+            "-Xopt-in=androidx.compose.ui.ExperimentalComposeUiApi",
+            "-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi"
         )
     }
 

+ 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
+        }
+    }
+}

+ 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,
+        )
+    }

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

@@ -0,0 +1,26 @@
+package eu.kanade.domain
+
+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
+import eu.kanade.domain.history.interactor.RemoveHistoryById
+import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
+import eu.kanade.domain.history.repository.HistoryRepository
+import uy.kohesive.injekt.api.InjektModule
+import uy.kohesive.injekt.api.InjektRegistrar
+import uy.kohesive.injekt.api.addFactory
+import uy.kohesive.injekt.api.addSingletonFactory
+import uy.kohesive.injekt.api.get
+
+class DomainModule : InjektModule {
+
+    override fun InjektRegistrar.registerInjectables() {
+        addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
+        addFactory { DeleteHistoryTable(get()) }
+        addFactory { GetHistory(get()) }
+        addFactory { GetNextChapterForManga(get()) }
+        addFactory { RemoveHistoryById(get()) }
+        addFactory { RemoveHistoryByMangaId(get()) }
+    }
+}

+ 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?
+)

+ 12 - 0
app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt

@@ -0,0 +1,12 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+
+class DeleteHistoryTable(
+    private val repository: HistoryRepository
+) {
+
+    suspend fun await(): Boolean {
+        return repository.deleteAllHistory()
+    }
+}

+ 21 - 0
app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt

@@ -0,0 +1,21 @@
+package eu.kanade.domain.history.interactor
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import eu.kanade.domain.history.model.HistoryWithRelations
+import eu.kanade.domain.history.repository.HistoryRepository
+import kotlinx.coroutines.flow.Flow
+
+class GetHistory(
+    private val repository: HistoryRepository
+) {
+
+    fun subscribe(query: String): Flow<PagingData<HistoryWithRelations>> {
+        return Pager(
+            PagingConfig(pageSize = 25)
+        ) {
+            repository.getHistory(query)
+        }.flow
+    }
+}

+ 13 - 0
app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt

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

+ 13 - 0
app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt

@@ -0,0 +1,13 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.model.HistoryWithRelations
+import eu.kanade.domain.history.repository.HistoryRepository
+
+class RemoveHistoryById(
+    private val repository: HistoryRepository
+) {
+
+    suspend fun await(history: HistoryWithRelations) {
+        repository.resetHistory(history.id)
+    }
+}

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

@@ -0,0 +1,12 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+
+class RemoveHistoryByMangaId(
+    private val repository: HistoryRepository
+) {
+
+    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?
+)

+ 18 - 0
app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt

@@ -0,0 +1,18 @@
+package eu.kanade.domain.history.repository
+
+import androidx.paging.PagingSource
+import eu.kanade.domain.chapter.model.Chapter
+import eu.kanade.domain.history.model.HistoryWithRelations
+
+interface HistoryRepository {
+
+    fun getHistory(query: String): PagingSource<Long, HistoryWithRelations>
+
+    suspend fun getNextChapterForManga(mangaId: Long, chapterId: Long): Chapter?
+
+    suspend fun resetHistory(historyId: Long)
+
+    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
+    }
+}

+ 49 - 0
app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt

@@ -0,0 +1,49 @@
+package eu.kanade.presentation.components
+
+import android.view.ViewGroup
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import eu.kanade.tachiyomi.widget.EmptyView
+
+@Composable
+fun EmptyScreen(
+    @StringRes textResource: Int,
+    actions: List<EmptyView.Action>? = null,
+) {
+    EmptyScreen(
+        message = stringResource(id = textResource),
+        actions = actions,
+    )
+}
+
+@Composable
+fun EmptyScreen(
+    message: String,
+    actions: List<EmptyView.Action>? = null,
+) {
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+    ) {
+        AndroidView(
+            factory = { context ->
+                EmptyView(context).apply {
+                    layoutParams = ViewGroup.LayoutParams(
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                    )
+                }
+            },
+            modifier = Modifier
+                .align(Alignment.Center),
+        ) { view ->
+            view.show(message, actions)
+        }
+    }
+}

+ 39 - 0
app/src/main/java/eu/kanade/presentation/components/MangaCover.kt

@@ -0,0 +1,39 @@
+package eu.kanade.presentation.components
+
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+
+enum class MangaCoverAspect(val ratio: Float) {
+    SQUARE(1f / 1f),
+    COVER(2f / 3f)
+}
+
+@Composable
+fun MangaCover(
+    modifier: Modifier = Modifier,
+    data: String?,
+    aspect: MangaCoverAspect,
+    contentDescription: String = "",
+    shape: Shape = RoundedCornerShape(4.dp)
+) {
+    AsyncImage(
+        model = data,
+        placeholder = ColorPainter(CoverPlaceholderColor),
+        contentDescription = contentDescription,
+        modifier = modifier
+            .aspectRatio(aspect.ratio)
+            .clip(shape),
+        contentScale = ContentScale.Crop
+    )
+}
+
+private val CoverPlaceholderColor = Color(0x1F888888)

+ 297 - 0
app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt

@@ -0,0 +1,297 @@
+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
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+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.util.horizontalPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter
+import eu.kanade.tachiyomi.ui.recent.history.UiModel
+import eu.kanade.tachiyomi.util.lang.toRelativeString
+import eu.kanade.tachiyomi.util.lang.toTimestampString
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.text.DateFormat
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+import java.util.Date
+
+@Composable
+fun HistoryScreen(
+    composeView: ComposeView,
+    presenter: HistoryPresenter,
+    onClickItem: (HistoryWithRelations) -> Unit,
+    onClickResume: (HistoryWithRelations) -> Unit,
+    onClickDelete: (HistoryWithRelations, Boolean) -> Unit,
+) {
+    val nestedScrollInterop = rememberNestedScrollInteropConnection(composeView)
+    val state by presenter.state.collectAsState()
+    val history = state.list?.collectAsLazyPagingItems()
+    when {
+        history == null -> {
+            CircularProgressIndicator()
+        }
+        history.itemCount == 0 -> {
+            EmptyScreen(
+                textResource = R.string.information_no_recent_manga
+            )
+        }
+        else -> {
+            HistoryContent(
+                nestedScroll = nestedScrollInterop,
+                history = history,
+                onClickItem = onClickItem,
+                onClickResume = onClickResume,
+                onClickDelete = onClickDelete,
+            )
+        }
+    }
+}
+
+@Composable
+fun HistoryContent(
+    history: LazyPagingItems<UiModel>,
+    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<HistoryWithRelations?>(null) }
+
+    val scrollState = rememberLazyListState()
+    LazyColumn(
+        modifier = Modifier
+            .nestedScroll(nestedScroll),
+        state = scrollState,
+    ) {
+        items(history) { item ->
+            when (item) {
+                is UiModel.Header -> {
+                    HistoryHeader(
+                        modifier = Modifier
+                            .animateItemPlacement(),
+                        date = item.date,
+                        relativeTime = relativeTime,
+                        dateFormat = dateFormat
+                    )
+                }
+                is UiModel.Item -> {
+                    val value = item.item
+                    HistoryItem(
+                        modifier = Modifier.animateItemPlacement(),
+                        history = value,
+                        onClickItem = { onClickItem(value) },
+                        onClickResume = { onClickResume(value) },
+                        onClickDelete = { setRemoveState(value) },
+                    )
+                }
+                null -> {}
+            }
+        }
+        item {
+            Spacer(Modifier.navigationBarsPadding())
+        }
+    }
+
+    if (removeState != null) {
+        RemoveHistoryDialog(
+            onPositive = { all ->
+                onClickDelete(removeState, all)
+                setRemoveState(null)
+            },
+            onNegative = { setRemoveState(null) }
+        )
+    }
+}
+
+@Composable
+fun HistoryHeader(
+    modifier: Modifier = Modifier,
+    date: Date,
+    relativeTime: Int,
+    dateFormat: DateFormat,
+) {
+    Text(
+        modifier = modifier
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        text = date.toRelativeString(
+            LocalContext.current,
+            relativeTime,
+            dateFormat
+        ),
+        style = MaterialTheme.typography.bodyMedium.copy(
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+            fontWeight = FontWeight.SemiBold,
+        )
+    )
+}
+
+@Composable
+fun HistoryItem(
+    modifier: Modifier = Modifier,
+    history: HistoryWithRelations,
+    onClickItem: () -> Unit,
+    onClickResume: () -> Unit,
+    onClickDelete: () -> Unit,
+) {
+    Row(
+        modifier = modifier
+            .clickable(onClick = onClickItem)
+            .height(96.dp)
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        MangaCover(
+            modifier = Modifier.fillMaxHeight(),
+            data = history.thumbnailUrl,
+            aspect = MangaCoverAspect.COVER
+        )
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = horizontalPadding, end = 8.dp),
+        ) {
+            val textStyle = MaterialTheme.typography.bodyMedium.copy(
+                color = MaterialTheme.colorScheme.onSurface,
+            )
+            Text(
+                text = history.title,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+                style = textStyle.copy(fontWeight = FontWeight.SemiBold)
+            )
+            Row {
+                Text(
+                    text = if (history.chapterNumber > -1) {
+                        stringResource(
+                            R.string.recent_manga_time,
+                            chapterFormatter.format(history.chapterNumber),
+                            history.readAt?.toTimestampString() ?: "",
+                        )
+                    } else {
+                        history.readAt?.toTimestampString() ?: ""
+                    },
+                    modifier = Modifier.padding(top = 4.dp),
+                    style = textStyle
+                )
+            }
+        }
+        IconButton(onClick = onClickDelete) {
+            Icon(
+                imageVector = Icons.Outlined.Delete,
+                contentDescription = stringResource(id = R.string.action_delete),
+                tint = MaterialTheme.colorScheme.onSurface,
+            )
+        }
+        IconButton(onClick = onClickResume) {
+            Icon(
+                imageVector = Icons.Filled.PlayArrow,
+                contentDescription = stringResource(id = R.string.action_resume),
+                tint = MaterialTheme.colorScheme.onSurface,
+            )
+        }
+    }
+}
+
+@Composable
+fun RemoveHistoryDialog(
+    onPositive: (Boolean) -> Unit,
+    onNegative: () -> Unit
+) {
+    val (removeEverything, removeEverythingState) = remember { mutableStateOf(false) }
+
+    AlertDialog(
+        title = {
+            Text(text = stringResource(id = R.string.action_remove))
+        },
+        text = {
+            Column {
+                Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description))
+                Row(
+                    modifier = Modifier
+                        .padding(top = 16.dp)
+                        .toggleable(
+                            interactionSource = remember { MutableInteractionSource() },
+                            indication = null,
+                            value = removeEverything,
+                            onValueChange = removeEverythingState
+                        ),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Checkbox(
+                        checked = removeEverything,
+                        onCheckedChange = null,
+                    )
+                    Text(
+                        modifier = Modifier.padding(start = 4.dp),
+                        text = stringResource(id = R.string.dialog_with_checkbox_reset)
+                    )
+                }
+            }
+        },
+        onDismissRequest = onNegative,
+        confirmButton = {
+            TextButton(onClick = { onPositive(removeEverything) }) {
+                Text(text = stringResource(id = R.string.action_remove))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onNegative) {
+                Text(text = stringResource(id = R.string.action_cancel))
+            }
+        },
+    )
+}
+
+private val chapterFormatter = DecimalFormat(
+    "#.###",
+    DecimalFormatSymbols().apply { decimalSeparator = '.' },
+)

+ 20 - 0
app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt

@@ -0,0 +1,20 @@
+package eu.kanade.presentation.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import com.google.android.material.composethemeadapter3.createMdc3Theme
+
+@Composable
+fun TachiyomiTheme(content: @Composable () -> Unit) {
+    val context = LocalContext.current
+    val (colorScheme, typography) = createMdc3Theme(
+        context = context
+    )
+
+    MaterialTheme(
+        colorScheme = colorScheme!!,
+        typography = typography!!,
+        content = content
+    )
+}

+ 5 - 0
app/src/main/java/eu/kanade/presentation/util/Constants.kt

@@ -0,0 +1,5 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.ui.unit.dp
+
+val horizontalPadding = 16.dp

+ 5 - 0
app/src/main/java/eu/kanade/presentation/util/LazyListState.kt

@@ -0,0 +1,5 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.foundation.lazy.LazyListState
+
+fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -24,6 +24,7 @@ import coil.decode.GifDecoder
 import coil.decode.ImageDecoderDecoder
 import coil.disk.DiskCache
 import coil.util.DebugLogger
+import eu.kanade.domain.DomainModule
 import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
 import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
 import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
@@ -75,6 +76,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
         }
 
         Injekt.importModule(AppModule(this))
+        Injekt.importModule(DomainModule())
 
         setupAcra()
         setupNotificationChannels()

+ 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>()

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt

@@ -299,7 +299,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
                 }
             }
         }
-        databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
+        databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking()
     }
 
     /**

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt

@@ -168,7 +168,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
                 }
             }
         }
-        databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
+        databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking()
     }
 
     /**

+ 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) {

+ 6 - 34
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.HistoryLastReadPutResolver
-import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver
+import eu.kanade.tachiyomi.data.database.resolvers.HistoryUpsertResolver
 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(
@@ -64,9 +36,9 @@ interface HistoryQueries : DbProvider {
      * Inserts history object if not yet in database
      * @param history history object
      */
-    fun updateHistoryLastRead(history: History) = db.put()
+    fun upsertHistoryLastRead(history: History) = db.put()
         .`object`(history)
-        .withPutResolver(HistoryLastReadPutResolver())
+        .withPutResolver(HistoryUpsertResolver())
         .prepare()
 
     /**
@@ -74,12 +46,12 @@ interface HistoryQueries : DbProvider {
      * Inserts history object if not yet in database
      * @param historyList history object list
      */
-    fun updateHistoryLastRead(historyList: List<History>) = db.put()
+    fun upsertHistoryLastRead(historyList: List<History>) = db.put()
         .objects(historyList)
-        .withPutResolver(HistoryLastReadPutResolver())
+        .withPutResolver(HistoryUpsertResolver())
         .prepare()
 
-    fun deleteHistory() = db.delete()
+    fun dropHistoryTable() = db.delete()
         .byQuery(
             DeleteQuery.builder()
                 .table(HistoryTable.TABLE)

+ 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}

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt → app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryUpsertResolver.kt

@@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.database.mappers.HistoryPutResolver
 import eu.kanade.tachiyomi.data.database.models.History
 import eu.kanade.tachiyomi.data.database.tables.HistoryTable
 
-class HistoryLastReadPutResolver : HistoryPutResolver() {
+class HistoryUpsertResolver : HistoryPutResolver() {
 
     /**
      * Updates last_read time of chapter

+ 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) {

+ 26 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt

@@ -0,0 +1,26 @@
+package eu.kanade.tachiyomi.ui.base.controller
+
+import android.view.LayoutInflater
+import android.view.View
+import androidx.compose.runtime.Composable
+import eu.kanade.presentation.theme.TachiyomiTheme
+import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
+import nucleus.presenter.Presenter
+
+abstract class ComposeController<P : Presenter<*>> : NucleusController<ComposeControllerBinding, P>() {
+
+    override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
+        ComposeControllerBinding.inflate(inflater)
+
+    override fun onViewCreated(view: View) {
+        super.onViewCreated(view)
+
+        binding.root.setContent {
+            TachiyomiTheme {
+                ComposeContent()
+            }
+        }
+    }
+
+    @Composable abstract fun ComposeContent()
+}

+ 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),

+ 0 - 24
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaCoverImageView.kt

@@ -1,24 +0,0 @@
-package eu.kanade.tachiyomi.ui.manga.info
-
-import android.content.Context
-import android.util.AttributeSet
-import androidx.appcompat.widget.AppCompatImageView
-import kotlin.math.min
-
-/**
- * A custom ImageView for holding a manga cover with:
- * - width: min(maxWidth attr, 33% of parent width)
- * - height: 2:3 width:height ratio
- *
- * Should be defined with a width of match_parent.
- */
-class MangaCoverImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) {
-
-    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
-        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
-
-        val width = min(maxWidth, measuredWidth / 3)
-        val height = width / 2 * 3
-        setMeasuredDimension(width, height)
-    }
-}

+ 8 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

@@ -97,14 +97,19 @@ import kotlin.math.max
 class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
 
     companion object {
-        fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
+
+        fun newIntent(context: Context, mangaId: Long?, chapterId: Long?): Intent {
             return Intent(context, ReaderActivity::class.java).apply {
-                putExtra("manga", manga.id)
-                putExtra("chapter", chapter.id)
+                putExtra("manga", mangaId)
+                putExtra("chapter", chapterId)
                 addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
             }
         }
 
+        fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
+            return newIntent(context, manga.id, chapter.id)
+        }
+
         private const val ENABLED_BUTTON_IMAGE_ALPHA = 255
         private const val DISABLED_BUTTON_IMAGE_ALPHA = 64
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -449,7 +449,7 @@ class ReaderPresenter(
     private fun saveChapterHistory(chapter: ReaderChapter) {
         if (!incognitoMode) {
             val history = History.create(chapter.chapter).apply { last_read = Date().time }
-            db.updateHistoryLastRead(history).asRxCompletable()
+            db.upsertHistoryLastRead(history).asRxCompletable()
                 .onErrorComplete()
                 .subscribeOn(Schedulers.io())
                 .subscribe()

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/ClearHistoryDialogController.kt

@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.ui.recent.history
+
+import android.app.Dialog
+import android.os.Bundle
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+
+class ClearHistoryDialogController : DialogController() {
+    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
+        return MaterialAlertDialogBuilder(activity!!)
+            .setMessage(R.string.clear_history_confirmation)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                (targetController as? HistoryController)
+                    ?.presenter
+                    ?.deleteAllHistory()
+            }
+            .setNegativeButton(android.R.string.cancel, null)
+            .create()
+    }
+}

+ 0 - 51
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt

@@ -1,51 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.source.SourceManager
-import uy.kohesive.injekt.injectLazy
-import java.text.DecimalFormat
-import java.text.DecimalFormatSymbols
-
-/**
- * Adapter of HistoryHolder.
- * Connection between Fragment and Holder
- * Holder updates should be called from here.
- *
- * @param controller a HistoryController object
- * @constructor creates an instance of the adapter.
- */
-class HistoryAdapter(controller: HistoryController) :
-    FlexibleAdapter<IFlexible<*>>(null, controller, true) {
-
-    val sourceManager: SourceManager by injectLazy()
-
-    val resumeClickListener: OnResumeClickListener = controller
-    val removeClickListener: OnRemoveClickListener = controller
-    val itemClickListener: OnItemClickListener = controller
-
-    /**
-     * DecimalFormat used to display correct chapter number
-     */
-    val decimalFormat = DecimalFormat(
-        "#.###",
-        DecimalFormatSymbols()
-            .apply { decimalSeparator = '.' },
-    )
-
-    init {
-        setDisplayHeadersAtStartUp(true)
-    }
-
-    interface OnResumeClickListener {
-        fun onResumeClick(position: Int)
-    }
-
-    interface OnRemoveClickListener {
-        fun onRemoveClick(position: Int)
-    }
-
-    interface OnItemClickListener {
-        fun onItemClick(position: Int)
-    }
-}

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

@@ -1,193 +1,53 @@
 package eu.kanade.tachiyomi.ui.recent.history
 
-import android.app.Dialog
-import android.os.Bundle
-import android.view.LayoutInflater
 import android.view.Menu
 import android.view.MenuInflater
 import android.view.MenuItem
-import android.view.View
 import androidx.appcompat.widget.SearchView
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.chrisbanes.insetter.applyInsetter
-import eu.davidea.flexibleadapter.FlexibleAdapter
+import androidx.compose.runtime.Composable
+import eu.kanade.domain.chapter.model.Chapter
+import eu.kanade.presentation.history.HistoryScreen
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.backup.BackupRestoreService
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.databinding.HistoryControllerBinding
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import eu.kanade.tachiyomi.ui.base.controller.ComposeController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
-import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem
-import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.ui.reader.ReaderActivity
-import eu.kanade.tachiyomi.util.system.logcat
 import eu.kanade.tachiyomi.util.system.toast
-import eu.kanade.tachiyomi.util.view.onAnimationsFinished
-import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import logcat.LogPriority
 import reactivecircus.flowbinding.appcompat.queryTextChanges
-import uy.kohesive.injekt.injectLazy
 
-/**
- * Fragment that shows recently read manga.
- */
-class HistoryController :
-    NucleusController<HistoryControllerBinding, HistoryPresenter>(),
-    RootController,
-    FlexibleAdapter.OnUpdateListener,
-    FlexibleAdapter.EndlessScrollListener,
-    HistoryAdapter.OnRemoveClickListener,
-    HistoryAdapter.OnResumeClickListener,
-    HistoryAdapter.OnItemClickListener,
-    RemoveHistoryDialog.Listener {
+class HistoryController : ComposeController<HistoryPresenter>(), RootController {
 
-    private val db: DatabaseHelper by injectLazy()
-
-    /**
-     * Adapter containing the recent manga.
-     */
-    var adapter: HistoryAdapter? = null
-        private set
-
-    /**
-     * Endless loading item.
-     */
-    private var progressItem: ProgressItem? = null
-
-    /**
-     * Search query.
-     */
     private var query = ""
 
-    override fun getTitle(): String? {
-        return resources?.getString(R.string.label_recent_manga)
-    }
-
-    override fun createPresenter(): HistoryPresenter {
-        return HistoryPresenter()
-    }
-
-    override fun createBinding(inflater: LayoutInflater) = HistoryControllerBinding.inflate(inflater)
-
-    override fun onViewCreated(view: View) {
-        super.onViewCreated(view)
-
-        binding.recycler.applyInsetter {
-            type(navigationBars = true) {
-                padding()
-            }
-        }
-
-        // Initialize adapter
-        binding.recycler.layoutManager = LinearLayoutManager(view.context)
-        adapter = HistoryAdapter(this@HistoryController)
-        binding.recycler.setHasFixedSize(true)
-        binding.recycler.adapter = adapter
-        adapter?.fastScroller = binding.fastScroller
-    }
-
-    override fun onDestroyView(view: View) {
-        adapter = null
-        super.onDestroyView(view)
-    }
-
-    /**
-     * Populate adapter with chapters
-     *
-     * @param mangaHistory list of manga history
-     */
-    fun onNextManga(mangaHistory: List<HistoryItem>, cleanBatch: Boolean = false) {
-        if (adapter?.itemCount ?: 0 == 0) {
-            resetProgressItem()
-        }
-        if (cleanBatch) {
-            adapter?.updateDataSet(mangaHistory)
-        } else {
-            adapter?.onLoadMoreComplete(mangaHistory)
-        }
-        binding.recycler.onAnimationsFinished {
-            (activity as? MainActivity)?.ready = true
-        }
-    }
-
-    /**
-     * Safely error if next page load fails
-     */
-    fun onAddPageError(error: Throwable) {
-        adapter?.onLoadMoreComplete(null)
-        adapter?.endlessTargetCount = 1
-        logcat(LogPriority.ERROR, error)
-    }
-
-    override fun onUpdateEmptyView(size: Int) {
-        if (size > 0) {
-            binding.emptyView.hide()
-        } else {
-            binding.emptyView.show(R.string.information_no_recent_manga)
-        }
-    }
-
-    /**
-     * Sets a new progress item and reenables the scroll listener.
-     */
-    private fun resetProgressItem() {
-        progressItem = ProgressItem()
-        adapter?.endlessTargetCount = 0
-        adapter?.setEndlessScrollListener(this, progressItem!!)
-    }
-
-    override fun onLoadMore(lastPosition: Int, currentPage: Int) {
-        val view = view ?: return
-        if (BackupRestoreService.isRunning(view.context.applicationContext)) {
-            onAddPageError(Throwable())
-            return
-        }
-        val adapter = adapter ?: return
-        presenter.requestNext(adapter.itemCount - adapter.headerItems.size, query)
-    }
-
-    override fun noMoreLoad(newItemsSize: Int) {}
-
-    override fun onResumeClick(position: Int) {
-        val activity = activity ?: return
-        val (manga, chapter, _) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return
-
-        val nextChapter = presenter.getNextChapter(chapter, manga)
-        if (nextChapter != null) {
-            val intent = ReaderActivity.newIntent(activity, manga, nextChapter)
-            startActivity(intent)
-        } else {
-            activity.toast(R.string.no_next_chapter)
-        }
-    }
-
-    override fun onRemoveClick(position: Int) {
-        val (manga, _, history) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return
-        RemoveHistoryDialog(this, manga, history).showDialog(router)
-    }
-
-    override fun onItemClick(position: Int) {
-        val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return
-        router.pushController(MangaController(manga).withFadeTransaction())
-    }
-
-    override fun removeHistory(manga: Manga, history: History, all: Boolean) {
-        if (all) {
-            // Reset last read of chapter to 0L
-            presenter.removeAllFromHistory(manga.id!!)
-        } else {
-            // Remove all chapters belonging to manga from library
-            presenter.removeFromHistory(history)
-        }
+    override fun getTitle() = resources?.getString(R.string.label_recent_manga)
+
+    override fun createPresenter() = HistoryPresenter()
+
+    @Composable
+    override fun ComposeContent() {
+        HistoryScreen(
+            composeView = binding.root,
+            presenter = presenter,
+            onClickItem = { history ->
+                router.pushController(MangaController(history).withFadeTransaction())
+            },
+            onClickResume = { history ->
+                presenter.getNextChapterForManga(history.mangaId, history.chapterId)
+            },
+            onClickDelete = { history, all ->
+                if (all) {
+                    // Reset last read of chapter to 0L
+                    presenter.removeAllFromHistory(history.mangaId)
+                } else {
+                    // Remove all chapters belonging to manga from library
+                    presenter.removeFromHistory(history)
+                }
+            },
+        )
     }
 
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -201,46 +61,33 @@ class HistoryController :
             searchView.clearFocus()
         }
         searchView.queryTextChanges()
-            .drop(1) // Drop first event after subscribed
             .filter { router.backstack.lastOrNull()?.controller == this }
             .onEach {
                 query = it.toString()
-                presenter.updateList(query)
+                presenter.search(query)
             }
             .launchIn(viewScope)
-
-        // Fixes problem with the overflow icon showing up in lieu of search
-        searchItem.fixExpand(
-            onExpand = { invalidateMenuOnExpand() },
-        )
     }
 
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
+        return when (item.itemId) {
             R.id.action_clear_history -> {
-                val ctrl = ClearHistoryDialogController()
-                ctrl.targetController = this@HistoryController
-                ctrl.showDialog(router)
+                val dialog = ClearHistoryDialogController()
+                dialog.targetController = this@HistoryController
+                dialog.showDialog(router)
+                true
             }
+            else -> super.onOptionsItemSelected(item)
         }
-
-        return super.onOptionsItemSelected(item)
     }
 
-    class ClearHistoryDialogController : DialogController() {
-        override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-            return MaterialAlertDialogBuilder(activity!!)
-                .setMessage(R.string.clear_history_confirmation)
-                .setPositiveButton(android.R.string.ok) { _, _ ->
-                    (targetController as? HistoryController)?.clearHistory()
-                }
-                .setNegativeButton(android.R.string.cancel, null)
-                .create()
+    fun openChapter(chapter: Chapter?) {
+        val activity = activity ?: return
+        if (chapter != null) {
+            val intent = ReaderActivity.newIntent(activity, chapter.mangaId, chapter.id)
+            startActivity(intent)
+        } else {
+            activity.toast(R.string.no_next_chapter)
         }
     }
-
-    private fun clearHistory() {
-        db.deleteHistory().executeAsBlocking()
-        activity?.toast(R.string.clear_history_completed)
-    }
 }

+ 0 - 71
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt

@@ -1,71 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import android.view.View
-import coil.dispose
-import coil.load
-import eu.davidea.viewholders.FlexibleViewHolder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import eu.kanade.tachiyomi.databinding.HistoryItemBinding
-import eu.kanade.tachiyomi.util.lang.toTimestampString
-import java.util.Date
-
-/**
- * Holder that contains recent manga item
- * Uses R.layout.item_recently_read.
- * UI related actions should be called from here.
- *
- * @param view the inflated view for this holder.
- * @param adapter the adapter handling this holder.
- * @constructor creates a new recent chapter holder.
- */
-class HistoryHolder(
-    view: View,
-    val adapter: HistoryAdapter,
-) : FlexibleViewHolder(view, adapter) {
-
-    private val binding = HistoryItemBinding.bind(view)
-
-    init {
-        binding.holder.setOnClickListener {
-            adapter.itemClickListener.onItemClick(bindingAdapterPosition)
-        }
-
-        binding.remove.setOnClickListener {
-            adapter.removeClickListener.onRemoveClick(bindingAdapterPosition)
-        }
-
-        binding.resume.setOnClickListener {
-            adapter.resumeClickListener.onResumeClick(bindingAdapterPosition)
-        }
-    }
-
-    /**
-     * Set values of view
-     *
-     * @param item item containing history information
-     */
-    fun bind(item: MangaChapterHistory) {
-        // Retrieve objects
-        val (manga, chapter, history) = item
-
-        // Set manga title
-        binding.mangaTitle.text = manga.title
-
-        // Set chapter number + timestamp
-        if (chapter.chapter_number > -1f) {
-            val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
-            binding.mangaSubtitle.text = itemView.context.getString(
-                R.string.recent_manga_time,
-                formattedNumber,
-                Date(history.last_read).toTimestampString(),
-            )
-        } else {
-            binding.mangaSubtitle.text = Date(history.last_read).toTimestampString()
-        }
-
-        // Set cover
-        binding.cover.dispose()
-        binding.cover.load(item.manga)
-    }
-}

+ 0 - 42
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt

@@ -1,42 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractSectionableItem
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import eu.kanade.tachiyomi.ui.recent.DateSectionItem
-
-class HistoryItem(val mch: MangaChapterHistory, header: DateSectionItem) :
-    AbstractSectionableItem<HistoryHolder, DateSectionItem>(header) {
-
-    override fun getLayoutRes(): Int {
-        return R.layout.history_item
-    }
-
-    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): HistoryHolder {
-        return HistoryHolder(view, adapter as HistoryAdapter)
-    }
-
-    override fun bindViewHolder(
-        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
-        holder: HistoryHolder,
-        position: Int,
-        payloads: List<Any?>?,
-    ) {
-        holder.bind(mch)
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (other is HistoryItem) {
-            return mch.manga.id == other.mch.manga.id
-        }
-        return false
-    }
-
-    override fun hashCode(): Int {
-        return mch.manga.id!!.hashCode()
-    }
-}

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

@@ -1,157 +1,127 @@
 package eu.kanade.tachiyomi.ui.recent.history
 
 import android.os.Bundle
-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.models.MangaChapterHistory
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.insertSeparators
+import androidx.paging.map
+import eu.kanade.domain.history.interactor.DeleteHistoryTable
+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.ui.base.presenter.BasePresenter
-import eu.kanade.tachiyomi.ui.recent.DateSectionItem
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.lang.launchUI
 import eu.kanade.tachiyomi.util.lang.toDateKey
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import uy.kohesive.injekt.injectLazy
-import java.text.DateFormat
-import java.util.Calendar
-import java.util.Date
-import java.util.TreeMap
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.util.*
 
 /**
  * Presenter of HistoryFragment.
  * Contains information and data for fragment.
  * Observable updates should be called from here.
  */
-class HistoryPresenter : BasePresenter<HistoryController>() {
-
-    private val db: DatabaseHelper by injectLazy()
-    private val preferences: PreferencesHelper by injectLazy()
-
-    private val relativeTime: Int = preferences.relativeTime().get()
-    private val dateFormat: DateFormat = preferences.dateFormat()
-
-    private var recentMangaSubscription: Subscription? = null
+class HistoryPresenter(
+    private val getHistory: GetHistory = Injekt.get(),
+    private val getNextChapterForManga: GetNextChapterForManga = Injekt.get(),
+    private val deleteHistoryTable: DeleteHistoryTable = Injekt.get(),
+    private val removeHistoryById: RemoveHistoryById = Injekt.get(),
+    private val removeHistoryByMangaId: RemoveHistoryByMangaId = Injekt.get(),
+) : BasePresenter<HistoryController>() {
+
+    private var _query: MutableStateFlow<String> = MutableStateFlow("")
+    private var _state: MutableStateFlow<HistoryState> = MutableStateFlow(HistoryState.EMPTY)
+    val state: StateFlow<HistoryState> = _state
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
-        // Used to get a list of recently read manga
-        updateList()
-    }
-
-    fun requestNext(offset: Int, search: String = "") {
-        getRecentMangaObservable(offset = offset, search = search)
-            .subscribeLatestCache(
-                { view, mangas ->
-                    view.onNextManga(mangas)
-                },
-                HistoryController::onAddPageError,
-            )
-    }
-
-    /**
-     * Get recent manga observable
-     * @return list of history
-     */
-    private fun getRecentMangaObservable(limit: Int = 25, offset: Int = 0, search: String = ""): Observable<List<HistoryItem>> {
-        // Set date limit for recent manga
-        val cal = Calendar.getInstance().apply {
-            time = Date()
-            add(Calendar.YEAR, -50)
-        }
-
-        return db.getRecentManga(cal.time, limit, offset, search).asRxObservable()
-            .map { recents ->
-                val map = TreeMap<Date, MutableList<MangaChapterHistory>> { d1, d2 -> d2.compareTo(d1) }
-                val byDay = recents
-                    .groupByTo(map) { it.history.last_read.toDateKey() }
-                byDay.flatMap { entry ->
-                    val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat)
-                    entry.value.map { HistoryItem(it, dateItem) }
-                }
+        presenterScope.launchIO {
+            _state.update { state ->
+                state.copy(
+                    list = _query.flatMapLatest { query ->
+                        getHistory.subscribe(query)
+                            .map { pagingData ->
+                                pagingData
+                                    .map {
+                                        UiModel.Item(it)
+                                    }
+                                    .insertSeparators { before, after ->
+                                        val beforeDate = before?.item?.readAt?.time?.toDateKey() ?: Date(0)
+                                        val afterDate = after?.item?.readAt?.time?.toDateKey() ?: Date(0)
+                                        when {
+                                            beforeDate.time != afterDate.time && afterDate.time != 0L -> UiModel.Header(afterDate)
+                                            // Return null to avoid adding a separator between two items.
+                                            else -> null
+                                        }
+                                    }
+                            }
+                    }
+                        .cachedIn(presenterScope),
+                )
             }
-            .observeOn(AndroidSchedulers.mainThread())
+        }
     }
 
-    /**
-     * Reset last read of chapter to 0L
-     * @param history history belonging to chapter
-     */
-    fun removeFromHistory(history: History) {
-        history.last_read = 0L
-        db.updateHistoryLastRead(history).asRxObservable()
-            .subscribe()
+    fun search(query: String) {
+        presenterScope.launchIO {
+            _query.emit(query)
+        }
     }
 
-    /**
-     * Pull a list of history from the db
-     * @param search a search query to use for filtering
-     */
-    fun updateList(search: String = "") {
-        recentMangaSubscription?.unsubscribe()
-        recentMangaSubscription = getRecentMangaObservable(search = search)
-            .subscribeLatestCache(
-                { view, mangas ->
-                    view.onNextManga(mangas, true)
-                },
-                HistoryController::onAddPageError,
-            )
+    fun removeFromHistory(history: HistoryWithRelations) {
+        presenterScope.launchIO {
+            removeHistoryById.await(history)
+        }
     }
 
-    /**
-     * Removes all chapters belonging to manga from history.
-     * @param mangaId id of manga
-     */
     fun removeAllFromHistory(mangaId: Long) {
-        db.getHistoryByMangaId(mangaId).asRxSingle()
-            .map { list ->
-                list.forEach { it.last_read = 0L }
-                db.updateHistoryLastRead(list).executeAsBlocking()
-            }
-            .subscribe()
+        presenterScope.launchIO {
+            removeHistoryByMangaId.await(mangaId)
+        }
     }
 
-    /**
-     * Retrieves the next chapter of the given one.
-     *
-     * @param chapter the chapter of the history object.
-     * @param manga the manga of the chapter.
-     */
-    fun getNextChapter(chapter: Chapter, manga: Manga): Chapter? {
-        if (!chapter.read) {
-            return chapter
+    fun getNextChapterForManga(mangaId: Long, chapterId: Long) {
+        presenterScope.launchIO {
+            val chapter = getNextChapterForManga.await(mangaId, chapterId)
+            launchUI {
+                view?.openChapter(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")
+    fun deleteAllHistory() {
+        presenterScope.launchIO {
+            val result = deleteHistoryTable.await()
+            if (!result) return@launchIO
+            launchUI {
+                view?.activity?.toast(R.string.clear_history_completed)
+            }
         }
+    }
+}
 
-        val chapters = db.getChapters(manga).executeAsBlocking()
-            .sortedWith { c1, c2 -> sortFunction(c1, c2) }
+sealed class UiModel {
+    data class Item(val item: HistoryWithRelations) : UiModel()
+    data class Header(val date: Date) : UiModel()
+}
 
-        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.chapter_number
+data class HistoryState(
+    val list: Flow<PagingData<UiModel>>? = null,
+) {
 
-                ((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")
-        }
+    companion object {
+        val EMPTY = HistoryState(null)
     }
 }

+ 0 - 54
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt

@@ -1,54 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import android.app.Dialog
-import android.os.Bundle
-import com.bluelinelabs.conductor.Controller
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-import eu.kanade.tachiyomi.widget.DialogCheckboxView
-
-class RemoveHistoryDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
-        where T : Controller, T : RemoveHistoryDialog.Listener {
-
-    private var manga: Manga? = null
-
-    private var history: History? = null
-
-    constructor(target: T, manga: Manga, history: History) : this() {
-        this.manga = manga
-        this.history = history
-        targetController = target
-    }
-
-    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-        val activity = activity!!
-
-        // Create custom view
-        val dialogCheckboxView = DialogCheckboxView(activity).apply {
-            setDescription(R.string.dialog_with_checkbox_remove_description)
-            setOptionDescription(R.string.dialog_with_checkbox_reset)
-        }
-
-        return MaterialAlertDialogBuilder(activity)
-            .setTitle(R.string.action_remove)
-            .setView(dialogCheckboxView)
-            .setPositiveButton(R.string.action_remove) { _, _ -> onPositive(dialogCheckboxView.isChecked()) }
-            .setNegativeButton(android.R.string.cancel, null)
-            .create()
-    }
-
-    private fun onPositive(checked: Boolean) {
-        val target = targetController as? Listener ?: return
-        val manga = manga ?: return
-        val history = history ?: return
-
-        target.removeHistory(manga, history, checked)
-    }
-
-    interface Listener {
-        fun removeHistory(manga: Manga, history: History, all: Boolean)
-    }
-}

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

@@ -9,7 +9,6 @@ import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
 import androidx.core.view.forEach
-import androidx.core.view.get
 import androidx.core.view.isVisible
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
@@ -37,7 +36,6 @@ class ClearDatabaseController :
     private var menu: Menu? = null
 
     private var actionFab: ExtendedFloatingActionButton? = null
-    private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
 
     init {
         setHasOptionsMenu(true)
@@ -143,7 +141,6 @@ class ClearDatabaseController :
 
     override fun cleanupFab(fab: ExtendedFloatingActionButton) {
         actionFab?.setOnClickListener(null)
-        actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) }
         actionFab = null
     }
 

+ 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>> {

+ 12 - 1
app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt

@@ -5,8 +5,10 @@ import android.os.Parcel
 import android.os.Parcelable
 import android.util.AttributeSet
 import android.view.View
+import androidx.compose.ui.platform.ComposeView
 import androidx.coordinatorlayout.R
 import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
 import androidx.core.view.doOnLayout
 import androidx.core.view.isVisible
 import androidx.customview.view.AbsSavedState
@@ -63,7 +65,16 @@ class TachiyomiCoordinatorLayout @JvmOverloads constructor(
         super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed)
         // Disable elevation overlay when tabs are visible
         if (canLiftAppBarOnScroll) {
-            appBarLayout?.isLifted = (dyConsumed != 0 || dyUnconsumed >= 0) && tabLayout?.isVisible == false
+            if (target is ComposeView) {
+                val scrollCondition = if (type == ViewCompat.TYPE_NON_TOUCH) {
+                    dyUnconsumed >= 0
+                } else {
+                    dyConsumed != 0 || dyUnconsumed >= 0
+                }
+                appBarLayout?.isLifted = scrollCondition && tabLayout?.isVisible == false
+            } else {
+                appBarLayout?.isLifted = (dyConsumed != 0 || dyUnconsumed >= 0) && tabLayout?.isVisible == false
+            }
         }
     }
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/widget/preference/ThemesPreferenceAdapter.kt

@@ -25,7 +25,7 @@ class ThemesPreferenceAdapter(private val clickListener: OnItemClickListener) :
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThemeViewHolder {
         val themeResIds = ThemingDelegate.getThemeResIds(themes[viewType], preferences.themeDarkAmoled().get())
         val themedContext = themeResIds.fold(parent.context) {
-                context, themeResId ->
+            context, themeResId ->
             ContextThemeWrapper(context, themeResId)
         }
 

+ 4 - 0
app/src/main/res/layout/compose_controller.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" />

+ 0 - 33
app/src/main/res/layout/history_controller.xml

@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:clipToPadding="false"
-        android:paddingTop="4dp"
-        android:paddingBottom="@dimen/action_toolbar_list_padding"
-        tools:listitem="@layout/history_item" />
-
-    <eu.kanade.tachiyomi.widget.MaterialFastScroll
-        android:id="@+id/fast_scroller"
-        android:layout_width="wrap_content"
-        android:layout_height="match_parent"
-        android:layout_gravity="end"
-        app:fastScrollerBubbleEnabled="false"
-        tools:visibility="visible" />
-
-    <eu.kanade.tachiyomi.widget.EmptyView
-        android:id="@+id/empty_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:visibility="gone" />
-
-</FrameLayout>

+ 0 - 85
app/src/main/res/layout/history_item.xml

@@ -1,85 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/holder"
-    android:layout_width="match_parent"
-    android:layout_height="96dp"
-    android:paddingStart="16dp"
-    android:paddingTop="8dp"
-    android:paddingEnd="8dp"
-    android:paddingBottom="8dp"
-    android:background="?attr/selectableItemBackground"
-    android:orientation="horizontal">
-
-    <com.google.android.material.imageview.ShapeableImageView
-        android:id="@+id/cover"
-        android:layout_width="0dp"
-        android:layout_height="match_parent"
-        android:contentDescription="@string/description_cover"
-        android:scaleType="centerCrop"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintDimensionRatio="h,3:2"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
-        tools:src="@mipmap/ic_launcher" />
-
-    <LinearLayout
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="16dp"
-        android:layout_marginEnd="8dp"
-        android:orientation="vertical"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/remove"
-        app:layout_constraintStart_toEndOf="@+id/cover"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <TextView
-            android:id="@+id/manga_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:ellipsize="end"
-            android:maxLines="2"
-            android:textAppearance="?attr/textAppearanceTitleSmall"
-            tools:text="Title" />
-
-        <TextView
-            android:id="@+id/manga_subtitle"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="2dp"
-            android:textAppearance="?attr/textAppearanceBodyMedium"
-            android:textColor="?android:attr/textColorSecondary"
-            tools:text="Subtitle" />
-
-    </LinearLayout>
-
-    <ImageButton
-        android:id="@+id/remove"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackgroundBorderless"
-        android:contentDescription="@string/action_resume"
-        android:padding="8dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/resume"
-        app:layout_constraintTop_toTopOf="parent"
-        app:srcCompat="@drawable/ic_delete_24dp"
-        app:tint="?android:attr/textColorPrimary" />
-
-    <ImageButton
-        android:id="@+id/resume"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackgroundBorderless"
-        android:contentDescription="@string/action_resume"
-        android:padding="8dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:srcCompat="@drawable/ic_play_arrow_24dp"
-        app:tint="?android:attr/textColorPrimary" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 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
+);

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

@@ -0,0 +1,29 @@
+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
+);
+
+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;
+
+getChapterById:
+SELECT *
+FROM chapters
+WHERE _id = :id;
+
+getChapterByMangaId:
+SELECT *
+FROM chapters
+WHERE manga_id = :mangaId;

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

@@ -0,0 +1,37 @@
+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
+);
+
+CREATE INDEX history_history_chapter_id_index ON history(history_chapter_id);
+
+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
+);

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

@@ -0,0 +1,31 @@
+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
+);
+
+CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1;
+CREATE INDEX mangas_url_index ON mangas(url);
+
+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 - 1
app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

@@ -344,7 +344,7 @@ class BackupTest {
 
     private fun clearDatabase() {
         db.deleteMangas().executeAsBlocking()
-        db.deleteHistory().executeAsBlocking()
+        db.dropHistoryTable().executeAsBlocking()
     }
 
     private fun getSingleHistory(chapter: Chapter): DHistory {

+ 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")
     }
 }
 

+ 3 - 0
gradle/androidx.versions.toml

@@ -21,6 +21,9 @@ lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", ve
 work-runtime = "androidx.work:work-runtime-ktx:2.6.0"
 guava = "com.google.guava:guava:31.1-android"
 
+paging-runtime = "androidx.paging:paging-runtime:3.1.1"
+paging-compose = "androidx.paging:paging-compose:1.0.0-alpha14"
+
 [bundles]
 lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"]
 workmanager = ["work-runtime", "guava"]

+ 9 - 0
gradle/compose.versions.toml

@@ -0,0 +1,9 @@
+[versions]
+compose = "1.2.0-alpha07"
+
+[libraries]
+foundation = { module = "androidx.compose.foundation:foundation", version.ref="compose" }
+material3-core = "androidx.compose.material3:material3:1.0.0-alpha09"
+material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.6"
+animation = { module = "androidx.compose.animation:animation", version.ref="compose" }
+ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref="compose" }

+ 1 - 1
gradle/kotlinx.versions.toml

@@ -1,5 +1,5 @@
 [versions]
-kotlin_version = "1.6.20"
+kotlin_version = "1.6.10"
 coroutines_version = "1.6.1"
 serialization_version = "1.3.2"
 

+ 9 - 3
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"
@@ -49,6 +50,7 @@ injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440"
 
 coil-core = { module = "io.coil-kt:coil", version.ref = "coil_version" }
 coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil_version" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil_version" }
 
 subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:846abe0"
 image-decoder = "com.github.tachiyomiorg:image-decoder:7481a4a"
@@ -78,7 +80,7 @@ flowbinding-viewpager = { module = "io.github.reactivecircus.flowbinding:flowbin
 
 logcat = "com.squareup.logcat:logcat:0.1"
 
-acra-http = "ch.acra:acra-http:5.9.3"
+acra-http = "ch.acra:acra-http:5.9.1"
 firebase-analytics = "com.google.firebase:firebase-analytics-ktx:20.0.2"
 
 aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlib_version" }
@@ -96,18 +98,22 @@ 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"]
 js-engine = ["quickjs-android", "duktape-android"]
 sqlite = ["sqlitektx", "sqlite-android"]
 nucleus = ["nucleus-core","nucleus-supportv7"]
-coil = ["coil-core","coil-gif",]
+coil = ["coil-core","coil-gif","coil-compose"]
 flowbinding = ["flowbinding-android","flowbinding-appcompat","flowbinding-recyclerview","flowbinding-swiperefreshlayout","flowbinding-viewpager"]
 conductor = ["conductor-core","conductor-viewpager","conductor-support-preference"]
 shizuku = ["shizuku-api","shizuku-provider"]
 robolectric = ["robolectric-core","robolectric-playservices"]
 
 [plugins]
-kotlinter = { id = "org.jmailen.kotlinter", version = "3.10.0"}
+kotlinter = { id = "org.jmailen.kotlinter", version = "3.6.0"}
 versionsx = { id = "com.github.ben-manes.versions", version = "0.42.0"}

+ 3 - 0
settings.gradle.kts

@@ -22,6 +22,9 @@ dependencyResolutionManagement {
         create("androidx") {
             from(files("gradle/androidx.versions.toml"))
         }
+        create("compose") {
+            from(files("gradle/compose.versions.toml"))
+        }
     }
     repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
     repositories {