diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e81e82764..075367387e 100644 --- a/app/build.gradle.kts +++ b/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" ) } diff --git a/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt b/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt new file mode 100644 index 0000000000..bd4d99fde2 --- /dev/null +++ b/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() + + override suspend fun await(inTransaction: Boolean, block: suspend Database.() -> T): T { + return dispatch(inTransaction, block) + } + + override suspend fun awaitList( + inTransaction: Boolean, + block: suspend Database.() -> Query + ): List { + return dispatch(inTransaction) { block(db).executeAsList() } + } + + override suspend fun awaitOne( + inTransaction: Boolean, + block: suspend Database.() -> Query + ): T { + return dispatch(inTransaction) { block(db).executeAsOne() } + } + + override suspend fun awaitOneOrNull( + inTransaction: Boolean, + block: suspend Database.() -> Query + ): T? { + return dispatch(inTransaction) { block(db).executeAsOneOrNull() } + } + + override fun subscribeToList(block: Database.() -> Query): Flow> { + return block(db).asFlow().mapToList(queryDispatcher) + } + + override fun subscribeToOne(block: Database.() -> Query): Flow { + return block(db).asFlow().mapToOne(queryDispatcher) + } + + override fun subscribeToOneOrNull(block: Database.() -> Query): Flow { + return block(db).asFlow().mapToOneOrNull(queryDispatcher) + } + + override fun subscribeToPagingSource( + countQuery: Database.() -> Query, + transacter: Database.() -> Transacter, + queryProvider: Database.(Long, Long) -> Query + ): PagingSource { + return QueryPagingSource( + countQuery = countQuery(db), + transacter = transacter(db), + dispatcher = queryDispatcher, + queryProvider = { limit, offset -> + queryProvider.invoke(db, limit, offset) + } + ) + } + + private suspend fun 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) } + } +} diff --git a/app/src/main/java/eu/kanade/data/DatabaseAdapter.kt b/app/src/main/java/eu/kanade/data/DatabaseAdapter.kt new file mode 100644 index 0000000000..445428b8b7 --- /dev/null +++ b/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 { + override fun decode(databaseValue: Long): Date = Date(databaseValue) + override fun encode(value: Date): Long = value.time +} + +private const val listOfStringsSeparator = ", " +val listOfStringsAdapter = object : ColumnAdapter, String> { + override fun decode(databaseValue: String) = + if (databaseValue.isEmpty()) { + listOf() + } else { + databaseValue.split(listOfStringsSeparator) + } + override fun encode(value: List) = value.joinToString(separator = listOfStringsSeparator) +} diff --git a/app/src/main/java/eu/kanade/data/DatabaseHandler.kt b/app/src/main/java/eu/kanade/data/DatabaseHandler.kt new file mode 100644 index 0000000000..a528b7010e --- /dev/null +++ b/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 await(inTransaction: Boolean = false, block: suspend Database.() -> T): T + + suspend fun awaitList( + inTransaction: Boolean = false, + block: suspend Database.() -> Query + ): List + + suspend fun awaitOne( + inTransaction: Boolean = false, + block: suspend Database.() -> Query + ): T + + suspend fun awaitOneOrNull( + inTransaction: Boolean = false, + block: suspend Database.() -> Query + ): T? + + fun subscribeToList(block: Database.() -> Query): Flow> + + fun subscribeToOne(block: Database.() -> Query): Flow + + fun subscribeToOneOrNull(block: Database.() -> Query): Flow + + fun subscribeToPagingSource( + countQuery: Database.() -> Query, + transacter: Database.() -> Transacter, + queryProvider: Database.(Long, Long) -> Query + ): PagingSource +} diff --git a/app/src/main/java/eu/kanade/data/TransactionContext.kt b/app/src/main/java/eu/kanade/data/TransactionContext.kt new file mode 100644 index 0000000000..156b4cdbad --- /dev/null +++ b/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 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 + + override val key: CoroutineContext.Key + 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() + } + } +} diff --git a/app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt b/app/src/main/java/eu/kanade/data/chapter/ChapterMapper.kt new file mode 100644 index 0000000000..05de96ef27 --- /dev/null +++ b/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, + ) + } diff --git a/app/src/main/java/eu/kanade/data/history/HistoryMapper.kt b/app/src/main/java/eu/kanade/data/history/HistoryMapper.kt new file mode 100644 index 0000000000..ff94b1d617 --- /dev/null +++ b/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 + ) +} diff --git a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt new file mode 100644 index 0000000000..15d2d26332 --- /dev/null +++ b/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 { + 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 + } + } +} diff --git a/app/src/main/java/eu/kanade/data/manga/MangaMapper.kt b/app/src/main/java/eu/kanade/data/manga/MangaMapper.kt new file mode 100644 index 0000000000..96ede4e5ba --- /dev/null +++ b/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, 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, + ) + } diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt new file mode 100644 index 0000000000..9462ae7f98 --- /dev/null +++ b/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 { HistoryRepositoryImpl(get()) } + addFactory { DeleteHistoryTable(get()) } + addFactory { GetHistory(get()) } + addFactory { GetNextChapterForManga(get()) } + addFactory { RemoveHistoryById(get()) } + addFactory { RemoveHistoryByMangaId(get()) } + } +} diff --git a/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt b/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt new file mode 100644 index 0000000000..6eff7c580b --- /dev/null +++ b/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? +) diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt b/app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt new file mode 100644 index 0000000000..bebf1209d3 --- /dev/null +++ b/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() + } +} diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt b/app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt new file mode 100644 index 0000000000..d2f8302b75 --- /dev/null +++ b/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> { + return Pager( + PagingConfig(pageSize = 25) + ) { + repository.getHistory(query) + }.flow + } +} diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt b/app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt new file mode 100644 index 0000000000..477408ca3a --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt b/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt new file mode 100644 index 0000000000..93012c266b --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt b/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt new file mode 100644 index 0000000000..f32fa5f7b2 --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/eu/kanade/domain/history/model/History.kt b/app/src/main/java/eu/kanade/domain/history/model/History.kt new file mode 100644 index 0000000000..b0ba506957 --- /dev/null +++ b/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? +) diff --git a/app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt b/app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt new file mode 100644 index 0000000000..5faa8eb24e --- /dev/null +++ b/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? +) diff --git a/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt b/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt new file mode 100644 index 0000000000..38e0f4192f --- /dev/null +++ b/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 + + suspend fun getNextChapterForManga(mangaId: Long, chapterId: Long): Chapter? + + suspend fun resetHistory(historyId: Long) + + suspend fun resetHistoryByMangaId(mangaId: Long) + + suspend fun deleteAllHistory(): Boolean +} diff --git a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt new file mode 100644 index 0000000000..9226c3f612 --- /dev/null +++ b/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?, + 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 + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt b/app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt new file mode 100644 index 0000000000..e94bef8275 --- /dev/null +++ b/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? = null, +) { + EmptyScreen( + message = stringResource(id = textResource), + actions = actions, + ) +} + +@Composable +fun EmptyScreen( + message: String, + actions: List? = 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) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt b/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt new file mode 100644 index 0000000000..33c8dfaf61 --- /dev/null +++ b/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) diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt new file mode 100644 index 0000000000..02bafed726 --- /dev/null +++ b/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, + 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(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 = '.' }, +) diff --git a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt new file mode 100644 index 0000000000..adb6644d20 --- /dev/null +++ b/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 + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Constants.kt b/app/src/main/java/eu/kanade/presentation/util/Constants.kt new file mode 100644 index 0000000000..fcf64d77b3 --- /dev/null +++ b/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 diff --git a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt new file mode 100644 index 0000000000..adf7cd80c4 --- /dev/null +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 32a290be7e..74ced7ff00 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 39c42dd645..c9147b6c6c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/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 { + AndroidSqliteDriver( + schema = Database.Schema, + context = app, + name = DbOpenCallback.DATABASE_NAME, + callback = get() + ) + } + + addSingletonFactory { + Database( + driver = get(), + historyAdapter = History.Adapter( + history_last_readAdapter = dateAdapter, + history_time_readAdapter = dateAdapter + ), + mangasAdapter = Mangas.Adapter( + genreAdapter = listOfStringsAdapter + ) + ) + } + + addSingletonFactory { 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() + get() + get() get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt index 933cc7b663..f12047891b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt +++ b/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() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt index 4984b242ce..8d42245e02 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt +++ b/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() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt index 474d2d6b8e..36c1d3d49a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt +++ b/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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt index 31702be039..ef541ac426 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt +++ b/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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt index 24b57f48ec..fd93497899 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt +++ b/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) = db.put() + fun upsertHistoryLastRead(historyList: List) = db.put() .objects(historyList) - .withPutResolver(HistoryLastReadPutResolver()) + .withPutResolver(HistoryUpsertResolver()) .prepare() - fun deleteHistory() = db.delete() + fun dropHistoryTable() = db.delete() .byQuery( DeleteQuery.builder() .table(HistoryTable.TABLE) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt index 6c1a424a06..3488119392 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt +++ b/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} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryUpsertResolver.kt similarity index 97% rename from app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryUpsertResolver.kt index 7bcba97f78..908aca16d3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt index 76ffd7187e..3697ee60ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CategoryTable.kt +++ b/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 - )""" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt index 793349119a..8914e6f3cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/ChapterTable.kt +++ b/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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt index 9d19544a49..4dfe9f0ddc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/HistoryTable.kt +++ b/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)" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt index 578a85bbc9..d39b32adfa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaCategoryTable.kt +++ b/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 - )""" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt index fe9e6845f9..5c32e16bc9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt +++ b/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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt index 5a9a8f239b..90c38d5375 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt +++ b/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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 26fc7dea77..594325527a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/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() val manga = db.getManga(mangaId).executeAsBlocking() val chapter = db.getChapter(chapterId).executeAsBlocking() if (manga != null && chapter != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ComposeController.kt new file mode 100644 index 0000000000..24c6719a6b --- /dev/null +++ b/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

> : NucleusController() { + + 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() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index c381f1194b..89f8c7295a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/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), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaCoverImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaCoverImageView.kt deleted file mode 100644 index f7e5daf1cc..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaCoverImageView.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 51fa73cc1e..9053864d8c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -97,14 +97,19 @@ import kotlin.math.max class ReaderActivity : BaseRxActivity() { 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index fb954bab7c..8debf6f3ca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/ClearHistoryDialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/ClearHistoryDialogController.kt new file mode 100644 index 0000000000..a4080a01d5 --- /dev/null +++ b/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() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt deleted file mode 100644 index 8c1a88ec8e..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt +++ /dev/null @@ -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>(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt index 333dea3d4d..f6877b1fca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt +++ b/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(), - RootController, - FlexibleAdapter.OnUpdateListener, - FlexibleAdapter.EndlessScrollListener, - HistoryAdapter.OnRemoveClickListener, - HistoryAdapter.OnResumeClickListener, - HistoryAdapter.OnItemClickListener, - RemoveHistoryDialog.Listener { +class HistoryController : ComposeController(), 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 getTitle() = resources?.getString(R.string.label_recent_manga) - override fun createPresenter(): HistoryPresenter { - return HistoryPresenter() - } + override fun createPresenter() = 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, 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) - } + @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 } - } - - 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() + else -> super.onOptionsItemSelected(item) } } - private fun clearHistory() { - db.deleteHistory().executeAsBlocking() - activity?.toast(R.string.clear_history_completed) + 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) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt deleted file mode 100644 index 8164e5cc89..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt deleted file mode 100644 index 58f9e0cc21..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt +++ /dev/null @@ -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(header) { - - override fun getLayoutRes(): Int { - return R.layout.history_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): HistoryHolder { - return HistoryHolder(view, adapter as HistoryAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: HistoryHolder, - position: Int, - payloads: List?, - ) { - 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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt index e8feb084d9..eff06438c6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt +++ b/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() { +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() { - 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 + private var _query: MutableStateFlow = MutableStateFlow("") + private var _state: MutableStateFlow = MutableStateFlow(HistoryState.EMPTY) + val state: StateFlow = _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> { - // 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> { 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) } - } - } - .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() - } - - /** - * 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, - ) - } - - /** - * 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() - } - - /** - * 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 - } - - val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { - Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } - Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } - Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) } - else -> throw NotImplementedError("Unknown sorting method") - } - - val chapters = db.getChapters(manga).executeAsBlocking() - .sortedWith { c1, c2 -> sortFunction(c1, c2) } - - val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id } - return when (manga.sorting) { - Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1) - Manga.CHAPTER_SORTING_NUMBER -> { - val chapterNumber = chapter.chapter_number - - ((currChapterIndex + 1) until chapters.size) - .map { chapters[it] } - .firstOrNull { - it.chapter_number > chapterNumber && - it.chapter_number <= chapterNumber + 1 + 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), + ) } - Manga.CHAPTER_SORTING_UPLOAD_DATE -> { - chapters.drop(currChapterIndex + 1) - .firstOrNull { it.date_upload >= chapter.date_upload } + } + } + + fun search(query: String) { + presenterScope.launchIO { + _query.emit(query) + } + } + + fun removeFromHistory(history: HistoryWithRelations) { + presenterScope.launchIO { + removeHistoryById.await(history) + } + } + + fun removeAllFromHistory(mangaId: Long) { + presenterScope.launchIO { + removeHistoryByMangaId.await(mangaId) + } + } + + fun getNextChapterForManga(mangaId: Long, chapterId: Long) { + presenterScope.launchIO { + val chapter = getNextChapterForManga.await(mangaId, chapterId) + launchUI { + view?.openChapter(chapter) + } + } + } + + fun deleteAllHistory() { + presenterScope.launchIO { + val result = deleteHistoryTable.await() + if (!result) return@launchIO + launchUI { + view?.activity?.toast(R.string.clear_history_completed) } - else -> throw NotImplementedError("Unknown sorting method") } } } + +sealed class UiModel { + data class Item(val item: HistoryWithRelations) : UiModel() + data class Header(val date: Date) : UiModel() +} + +data class HistoryState( + val list: Flow>? = null, +) { + + companion object { + val EMPTY = HistoryState(null) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt deleted file mode 100644 index 6243ed1d82..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt +++ /dev/null @@ -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(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt index a47075667a..9002efc416 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabaseController.kt +++ b/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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt index d209779a3b..192bd81a92 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/database/ClearDatabasePresenter.kt +++ b/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() { private val db = Injekt.get() + private val database = Injekt.get() private val sourceManager = Injekt.get() @@ -26,7 +28,7 @@ class ClearDatabasePresenter : BasePresenter() { fun clearDatabaseForSourceIds(sources: List) { db.deleteMangasNotInLibraryBySourceIds(sources).executeAsBlocking() - db.deleteHistoryNoLastRead().executeAsBlocking() + database.historyQueries.removeResettedHistory() } private fun getDatabaseSourcesObservable(): Observable> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt index e7d7db22ca..16b1c96a00 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt +++ b/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 + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ThemesPreferenceAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ThemesPreferenceAdapter.kt index 4c5bd324d7..38e71410f7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ThemesPreferenceAdapter.kt +++ b/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) } diff --git a/app/src/main/res/layout/compose_controller.xml b/app/src/main/res/layout/compose_controller.xml new file mode 100644 index 0000000000..617287296b --- /dev/null +++ b/app/src/main/res/layout/compose_controller.xml @@ -0,0 +1,4 @@ + + diff --git a/app/src/main/res/layout/history_controller.xml b/app/src/main/res/layout/history_controller.xml deleted file mode 100644 index d33aa20edb..0000000000 --- a/app/src/main/res/layout/history_controller.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/history_item.xml b/app/src/main/res/layout/history_item.xml deleted file mode 100644 index ab407a01bd..0000000000 --- a/app/src/main/res/layout/history_item.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/sqldelight/data/categories.sq b/app/src/main/sqldelight/data/categories.sq new file mode 100644 index 0000000000..628b6df7fa --- /dev/null +++ b/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 +); \ No newline at end of file diff --git a/app/src/main/sqldelight/data/chapters.sq b/app/src/main/sqldelight/data/chapters.sq new file mode 100644 index 0000000000..337b1163cc --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/src/main/sqldelight/data/history.sq b/app/src/main/sqldelight/data/history.sq new file mode 100644 index 0000000000..a798b325a4 --- /dev/null +++ b/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; diff --git a/app/src/main/sqldelight/data/manga_sync.sq b/app/src/main/sqldelight/data/manga_sync.sq new file mode 100644 index 0000000000..dcd18442bf --- /dev/null +++ b/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 +); \ No newline at end of file diff --git a/app/src/main/sqldelight/data/mangas.sq b/app/src/main/sqldelight/data/mangas.sq new file mode 100644 index 0000000000..30b462be6d --- /dev/null +++ b/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, + 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; \ No newline at end of file diff --git a/app/src/main/sqldelight/data/mangas_categories.sq b/app/src/main/sqldelight/data/mangas_categories.sq new file mode 100644 index 0000000000..6db91fe162 --- /dev/null +++ b/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 +); \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/1.sqm b/app/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000000..7ae4198c73 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/10.sqm b/app/src/main/sqldelight/migrations/10.sqm new file mode 100644 index 0000000000..20a2c8444f --- /dev/null +++ b/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 +); \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/11.sqm b/app/src/main/sqldelight/migrations/11.sqm new file mode 100644 index 0000000000..23b429acd7 --- /dev/null +++ b/app/src/main/sqldelight/migrations/11.sqm @@ -0,0 +1,2 @@ +ALTER TABLE mangas +ADD COLUMN next_update INTEGER DEFAULT 0; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/12.sqm b/app/src/main/sqldelight/migrations/12.sqm new file mode 100644 index 0000000000..c806234396 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/13.sqm b/app/src/main/sqldelight/migrations/13.sqm new file mode 100644 index 0000000000..78e1ece21f --- /dev/null +++ b/app/src/main/sqldelight/migrations/13.sqm @@ -0,0 +1,3 @@ +UPDATE chapters +SET date_upload = date_fetch +WHERE date_upload = 0; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/14.sqm b/app/src/main/sqldelight/migrations/14.sqm new file mode 100644 index 0000000000..9189583922 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/2.sqm b/app/src/main/sqldelight/migrations/2.sqm new file mode 100644 index 0000000000..345e24c6ec --- /dev/null +++ b/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); \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/3.sqm b/app/src/main/sqldelight/migrations/3.sqm new file mode 100644 index 0000000000..42ffba8990 --- /dev/null +++ b/app/src/main/sqldelight/migrations/3.sqm @@ -0,0 +1,2 @@ +ALTER TABLE chapters +ADD COLUMN bookmark INTEGER DEFAULT 0; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/4.sqm b/app/src/main/sqldelight/migrations/4.sqm new file mode 100644 index 0000000000..7fc06a612e --- /dev/null +++ b/app/src/main/sqldelight/migrations/4.sqm @@ -0,0 +1,2 @@ +ALTER TABLE chapters +ADD COLUMN scanlator TEXT DEFAULT NULL; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/5.sqm b/app/src/main/sqldelight/migrations/5.sqm new file mode 100644 index 0000000000..a1e3b83786 --- /dev/null +++ b/app/src/main/sqldelight/migrations/5.sqm @@ -0,0 +1,2 @@ +ALTER TABLE manga_sync +ADD COLUMN remote_url TEXT DEFAULT ''; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/6.sqm b/app/src/main/sqldelight/migrations/6.sqm new file mode 100644 index 0000000000..00ee92e223 --- /dev/null +++ b/app/src/main/sqldelight/migrations/6.sqm @@ -0,0 +1,2 @@ +ALTER TABLE manga_sync +ADD COLUMN library_id INTEGER; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/7.sqm b/app/src/main/sqldelight/migrations/7.sqm new file mode 100644 index 0000000000..03492df766 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/8.sqm b/app/src/main/sqldelight/migrations/8.sqm new file mode 100644 index 0000000000..dc47263a81 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/src/main/sqldelight/migrations/9.sqm b/app/src/main/sqldelight/migrations/9.sqm new file mode 100644 index 0000000000..6eb6473005 --- /dev/null +++ b/app/src/main/sqldelight/migrations/9.sqm @@ -0,0 +1,2 @@ +ALTER TABLE mangas +ADD COLUMN cover_last_modified INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/app/src/main/sqldelight/view/historyView.sq b/app/src/main/sqldelight/view/historyView.sq new file mode 100644 index 0000000000..2471f85aad --- /dev/null +++ b/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; diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt index 75fe1320c9..8815334045 100644 --- a/app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt +++ b/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 { diff --git a/build.gradle.kts b/build.gradle.kts index f1a44de2b0..efb20b7e92 100644 --- a/build.gradle.kts +++ b/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") } } diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index b051c785b0..6c49236bbe 100644 --- a/gradle/androidx.versions.toml +++ b/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"] diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml new file mode 100644 index 0000000000..6b271fc9ba --- /dev/null +++ b/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" } diff --git a/gradle/kotlinx.versions.toml b/gradle/kotlinx.versions.toml index 268e66b5f4..74448c71b5 100644 --- a/gradle/kotlinx.versions.toml +++ b/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" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 48409bfd9e..3f332967ac 100644 --- a/gradle/libs.versions.toml +++ b/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"} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 8c3fdeac31..170ba7daf5 100644 --- a/settings.gradle.kts +++ b/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 {