diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml index cbea0c1334..a5f1a73472 100644 --- a/.github/ISSUE_TEMPLATE/report_issue.yml +++ b/.github/ISSUE_TEMPLATE/report_issue.yml @@ -44,7 +44,7 @@ body: description: | If you're experiencing crashes, share the crash logs from **More → Settings → Advanced** then press **Dump crash logs**. placeholder: | - You can paste the crash logs in pure text or upload it as an attachment. + You can paste the crash logs in plain text or upload it as an attachment. - type: input id: aniyomi-version diff --git a/.github/mergify.yml b/.github/mergify.yml new file mode 100644 index 0000000000..4576b9da03 --- /dev/null +++ b/.github/mergify.yml @@ -0,0 +1,9 @@ +pull_request_rules: + - name: Automatically merge translations + conditions: + - "author=weblate" + - "-conflict" + - "current-day-of-week=Sat" + actions: + merge: + method: squash \ No newline at end of file diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000000..7b8888063c --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,14 @@ +{ + "extends": [ + "config:base" + ], + "schedule": ["every sunday"], + "ignoreDeps": [ + "androidx.core:core-splashscreen", + "androidx.work:work-runtime-ktx", + "info.android15.nucleus:nucleus-support-v7", + "info.android15.nucleus:nucleus", + "com.android.tools:r8", + "com.google.guava:guava" + ] +} diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 06ca19e33f..f25d210d11 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -24,16 +24,17 @@ jobs: uses: actions/dependency-review-action@v1 - name: Set up JDK 11 - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: java-version: 11 + distribution: adopt - name: Copy CI gradle.properties run: | mkdir -p ~/.gradle cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties - - name: Build app + - name: Build app and run unit tests uses: gradle/gradle-command-action@v2 with: - arguments: assembleStandardRelease + arguments: assembleStandardRelease testStandardReleaseUnitTest \ No newline at end of file diff --git a/.github/workflows/build_push.yml b/.github/workflows/build_push.yml index 342bc4ddd6..2acc9bd0cf 100644 --- a/.github/workflows/build_push.yml +++ b/.github/workflows/build_push.yml @@ -26,19 +26,20 @@ jobs: uses: gradle/wrapper-validation-action@v1 - name: Set up JDK 11 - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: java-version: 11 + distribution: adopt - name: Copy CI gradle.properties run: | mkdir -p ~/.gradle cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties - - name: Build app + - name: Build app and run unit tests uses: gradle/gradle-command-action@v2 with: - arguments: assembleStandardRelease + arguments: assembleStandardRelease testStandardReleaseUnitTest # Sign APK and create release for tags diff --git a/README.md b/README.md index 86d10f69d2..82519bb96a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Features include: * Online reading from a variety of sources * Local reading of downloaded content * A configurable reader with multiple viewers, reading directions and other settings. - * Tracker support: [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) + * Tracker support: [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) * Categories to organize your library * Light and dark themes * Schedule updating your library for new chapters diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 382261302f..80d4dfb274 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,25 +139,27 @@ android { kotlinCompilerExtensionVersion = compose.versions.compose.get() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } + sqldelight { database("Database") { packageName = "eu.kanade.tachiyomi" + dialect = "sqlite:3.24" sourceFolders = listOf("sqldelight") } database("AnimeDatabase") { packageName = "eu.kanade.tachiyomi.mi" + dialect = "sqlite:3.24" sourceFolders = listOf("sqldelightanime") } } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } } dependencies { @@ -169,12 +171,14 @@ dependencies { implementation(compose.material.icons) implementation(compose.animation) implementation(compose.ui.tooling) + implementation(compose.ui.util) implementation(compose.accompanist.webview) + implementation(compose.accompanist.swiperefresh) implementation(androidx.paging.runtime) implementation(androidx.paging.compose) - implementation(libs.sqldelight.sqlite) + implementation(androidx.sqlite) implementation(libs.sqldelight.android.driver) implementation(libs.sqldelight.coroutines) implementation(libs.sqldelight.android.paging) diff --git a/app/proguard-android-optimize.txt b/app/proguard-android-optimize.txt index ad490a86c4..1a85da105a 100644 --- a/app/proguard-android-optimize.txt +++ b/app/proguard-android-optimize.txt @@ -1,4 +1,3 @@ --allowaccessmodification -dontusemixedcaseclassnames -verbose diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 302bb72252..fa527b6935 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,24 +1,22 @@ -dontobfuscate -# Keep extension's common dependencies +# Keep common dependencies used in extensions -keep,allowoptimization class eu.kanade.tachiyomi.** { public protected *; } --keep,allowoptimization class androidx.preference.** { *; } +-keep,allowoptimization class androidx.preference.** { public protected *; } -keep,allowoptimization class android.content.** { *; } --keep,allowoptimization class kotlinx.coroutines.** { *; } -keep,allowoptimization class uy.kohesive.injekt.** { public protected *; } -keep,allowoptimization class android.test.base.** { *; } -keep,allowoptimization class kotlin.** { public protected *; } -keep,allowoptimization class kotlinx.coroutines.** { public protected *; } +-keep,allowoptimization class kotlinx.serialization.** { public protected *; } -keep,allowoptimization class okhttp3.** { public protected *; } -keep,allowoptimization class okio.** { public protected *; } -keep,allowoptimization class rx.** { public protected *; } -keep,allowoptimization class org.jsoup.** { public protected *; } --keep,allowoptimization class com.google.gson.** { public protected *; } --keep,allowoptimization class com.github.salomonbrys.kotson.** { public protected *; } -keep,allowoptimization class com.squareup.duktape.** { public protected *; } -keep,allowoptimization class app.cash.quickjs.** { public protected *; } -keep,allowoptimization class uy.kohesive.injekt.** { public protected *; } --keep,allowoptimization class is.xyz.mpv.** { *; } +-keep,allowoptimization class is.xyz.mpv.** { public protected *; } ##---------------Begin: proguard configuration for RxJava 1.x ---------- -dontwarn sun.misc.** @@ -39,30 +37,6 @@ -dontnote rx.internal.util.PlatformDependent ##---------------End: proguard configuration for RxJava 1.x ---------- -##---------------Begin: proguard configuration for Gson ---------- -# Gson uses generic type information stored in a class file when working with fields. Proguard -# removes such information by default, so configure it to keep all of it. --keepattributes Signature - -# For using GSON @Expose annotation --keepattributes *Annotation* - -# Gson specific classes --dontwarn sun.misc.** - -# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, -# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) --keep class * extends com.google.gson.TypeAdapter --keep class * implements com.google.gson.TypeAdapterFactory --keep class * implements com.google.gson.JsonSerializer --keep class * implements com.google.gson.JsonDeserializer - -# Prevent R8 from leaving Data object members always null --keepclassmembers,allowobfuscation class * { - @com.google.gson.annotations.SerializedName ; -} -##---------------End: proguard configuration for Gson ---------- - ##---------------Begin: proguard configuration for kotlinx.serialization ---------- -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations diff --git a/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt new file mode 100644 index 0000000000..4d1ef452d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt @@ -0,0 +1,25 @@ +package eu.kanade.core.util + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import rx.Observable +import rx.Observer + +fun Observable.asFlow(): Flow = callbackFlow { + val observer = object : Observer { + override fun onNext(t: T) { + trySend(t) + } + + override fun onError(e: Throwable) { + close(e) + } + + override fun onCompleted() { + close() + } + } + val subscription = subscribe(observer) + awaitClose { subscription.unsubscribe() } +} diff --git a/app/src/main/java/eu/kanade/data/AndroidAnimeDatabaseHandler.kt b/app/src/main/java/eu/kanade/data/AndroidAnimeDatabaseHandler.kt index 105163ea22..999acff195 100644 --- a/app/src/main/java/eu/kanade/data/AndroidAnimeDatabaseHandler.kt +++ b/app/src/main/java/eu/kanade/data/AndroidAnimeDatabaseHandler.kt @@ -19,7 +19,7 @@ class AndroidAnimeDatabaseHandler( val db: AnimeDatabase, private val driver: AndroidSqliteDriver, val queryDispatcher: CoroutineDispatcher = Dispatchers.IO, - val transactionDispatcher: CoroutineDispatcher = queryDispatcher + val transactionDispatcher: CoroutineDispatcher = queryDispatcher, ) : AnimeDatabaseHandler { val suspendingTransactionId = ThreadLocal() @@ -30,21 +30,21 @@ class AndroidAnimeDatabaseHandler( override suspend fun awaitList( inTransaction: Boolean, - block: suspend AnimeDatabase.() -> Query + block: suspend AnimeDatabase.() -> Query, ): List { return dispatch(inTransaction) { block(db).executeAsList() } } override suspend fun awaitOne( inTransaction: Boolean, - block: suspend AnimeDatabase.() -> Query + block: suspend AnimeDatabase.() -> Query, ): T { return dispatch(inTransaction) { block(db).executeAsOne() } } override suspend fun awaitOneOrNull( inTransaction: Boolean, - block: suspend AnimeDatabase.() -> Query + block: suspend AnimeDatabase.() -> Query, ): T? { return dispatch(inTransaction) { block(db).executeAsOneOrNull() } } @@ -64,7 +64,7 @@ class AndroidAnimeDatabaseHandler( override fun subscribeToPagingSource( countQuery: AnimeDatabase.() -> Query, transacter: AnimeDatabase.() -> Transacter, - queryProvider: AnimeDatabase.(Long, Long) -> Query + queryProvider: AnimeDatabase.(Long, Long) -> Query, ): PagingSource { return QueryPagingSource( countQuery = countQuery(db), @@ -72,7 +72,7 @@ class AndroidAnimeDatabaseHandler( dispatcher = queryDispatcher, queryProvider = { limit, offset -> queryProvider.invoke(db, limit, offset) - } + }, ) } diff --git a/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt b/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt index bd4d99fde2..fe579888f0 100644 --- a/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt +++ b/app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt @@ -19,7 +19,7 @@ class AndroidDatabaseHandler( val db: Database, private val driver: SqlDriver, val queryDispatcher: CoroutineDispatcher = Dispatchers.IO, - val transactionDispatcher: CoroutineDispatcher = queryDispatcher + val transactionDispatcher: CoroutineDispatcher = queryDispatcher, ) : DatabaseHandler { val suspendingTransactionId = ThreadLocal() @@ -30,21 +30,21 @@ class AndroidDatabaseHandler( override suspend fun awaitList( inTransaction: Boolean, - block: suspend Database.() -> Query + block: suspend Database.() -> Query, ): List { return dispatch(inTransaction) { block(db).executeAsList() } } override suspend fun awaitOne( inTransaction: Boolean, - block: suspend Database.() -> Query + block: suspend Database.() -> Query, ): T { return dispatch(inTransaction) { block(db).executeAsOne() } } override suspend fun awaitOneOrNull( inTransaction: Boolean, - block: suspend Database.() -> Query + block: suspend Database.() -> Query, ): T? { return dispatch(inTransaction) { block(db).executeAsOneOrNull() } } @@ -64,7 +64,7 @@ class AndroidDatabaseHandler( override fun subscribeToPagingSource( countQuery: Database.() -> Query, transacter: Database.() -> Transacter, - queryProvider: Database.(Long, Long) -> Query + queryProvider: Database.(Long, Long) -> Query, ): PagingSource { return QueryPagingSource( countQuery = countQuery(db), @@ -72,7 +72,7 @@ class AndroidDatabaseHandler( dispatcher = queryDispatcher, queryProvider = { limit, offset -> queryProvider.invoke(db, limit, offset) - } + }, ) } diff --git a/app/src/main/java/eu/kanade/data/AnimeDatabaseHandler.kt b/app/src/main/java/eu/kanade/data/AnimeDatabaseHandler.kt index 1591a86e9a..0ebc1ec2b4 100644 --- a/app/src/main/java/eu/kanade/data/AnimeDatabaseHandler.kt +++ b/app/src/main/java/eu/kanade/data/AnimeDatabaseHandler.kt @@ -12,17 +12,17 @@ interface AnimeDatabaseHandler { suspend fun awaitList( inTransaction: Boolean = false, - block: suspend AnimeDatabase.() -> Query + block: suspend AnimeDatabase.() -> Query, ): List suspend fun awaitOne( inTransaction: Boolean = false, - block: suspend AnimeDatabase.() -> Query + block: suspend AnimeDatabase.() -> Query, ): T suspend fun awaitOneOrNull( inTransaction: Boolean = false, - block: suspend AnimeDatabase.() -> Query + block: suspend AnimeDatabase.() -> Query, ): T? fun subscribeToList(block: AnimeDatabase.() -> Query): Flow> @@ -34,6 +34,6 @@ interface AnimeDatabaseHandler { fun subscribeToPagingSource( countQuery: AnimeDatabase.() -> Query, transacter: AnimeDatabase.() -> Transacter, - queryProvider: AnimeDatabase.(Long, Long) -> Query + queryProvider: AnimeDatabase.(Long, Long) -> Query, ): PagingSource } diff --git a/app/src/main/java/eu/kanade/data/DatabaseHandler.kt b/app/src/main/java/eu/kanade/data/DatabaseHandler.kt index a528b7010e..c0cb676be1 100644 --- a/app/src/main/java/eu/kanade/data/DatabaseHandler.kt +++ b/app/src/main/java/eu/kanade/data/DatabaseHandler.kt @@ -12,17 +12,17 @@ interface DatabaseHandler { suspend fun awaitList( inTransaction: Boolean = false, - block: suspend Database.() -> Query + block: suspend Database.() -> Query, ): List suspend fun awaitOne( inTransaction: Boolean = false, - block: suspend Database.() -> Query + block: suspend Database.() -> Query, ): T suspend fun awaitOneOrNull( inTransaction: Boolean = false, - block: suspend Database.() -> Query + block: suspend Database.() -> Query, ): T? fun subscribeToList(block: Database.() -> Query): Flow> @@ -34,6 +34,6 @@ interface DatabaseHandler { fun subscribeToPagingSource( countQuery: Database.() -> Query, transacter: Database.() -> Transacter, - queryProvider: Database.(Long, Long) -> Query + queryProvider: Database.(Long, Long) -> Query, ): PagingSource } diff --git a/app/src/main/java/eu/kanade/data/DatabaseUtils.kt b/app/src/main/java/eu/kanade/data/DatabaseUtils.kt new file mode 100644 index 0000000000..d46f4b1f8b --- /dev/null +++ b/app/src/main/java/eu/kanade/data/DatabaseUtils.kt @@ -0,0 +1,3 @@ +package eu.kanade.data + +fun Boolean.toLong() = if (this) 1L else 0L diff --git a/app/src/main/java/eu/kanade/data/TransactionContext.kt b/app/src/main/java/eu/kanade/data/TransactionContext.kt index 8cd86efcef..c5b2c84ee3 100644 --- a/app/src/main/java/eu/kanade/data/TransactionContext.kt +++ b/app/src/main/java/eu/kanade/data/TransactionContext.kt @@ -138,7 +138,7 @@ private suspend fun AndroidAnimeDatabaseHandler.createTransactionContext(): Coro * thread by cancelling the job. */ private suspend fun CoroutineDispatcher.acquireTransactionThread( - controlJob: Job + controlJob: Job, ): ContinuationInterceptor { return suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { @@ -159,8 +159,8 @@ private suspend fun CoroutineDispatcher.acquireTransactionThread( // Couldn't acquire a thread, cancel coroutine. continuation.cancel( IllegalStateException( - "Unable to acquire a thread to perform the database transaction.", ex - ) + "Unable to acquire a thread to perform the database transaction.", ex, + ), ) } } @@ -171,7 +171,7 @@ private suspend fun CoroutineDispatcher.acquireTransactionThread( */ private class TransactionElement( private val transactionThreadControlJob: Job, - val transactionDispatcher: ContinuationInterceptor + val transactionDispatcher: ContinuationInterceptor, ) : CoroutineContext.Element { companion object Key : CoroutineContext.Key diff --git a/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt index 6d97a021df..9b24f2c72f 100644 --- a/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/anime/AnimeRepositoryImpl.kt @@ -3,13 +3,25 @@ package eu.kanade.data.anime import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.anime.repository.AnimeRepository +import eu.kanade.tachiyomi.util.system.logcat import kotlinx.coroutines.flow.Flow +import logcat.LogPriority class AnimeRepositoryImpl( - private val databaseHandler: AnimeDatabaseHandler + private val databaseHandler: AnimeDatabaseHandler, ) : AnimeRepository { override fun getFavoritesBySourceId(sourceId: Long): Flow> { return databaseHandler.subscribeToList { animesQueries.getFavoriteBySourceId(sourceId, animeMapper) } } + + override suspend fun resetViewerFlags(): Boolean { + return try { + databaseHandler.await { animesQueries.resetViewerFlags() } + true + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + false + } + } } diff --git a/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryMapper.kt b/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryMapper.kt index d529c573f1..6dfe3e77b9 100644 --- a/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryMapper.kt +++ b/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryMapper.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.animehistory.model.AnimeHistory import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import java.util.Date -val animehistoryMapper: (Long, Long, Date?, Date?) -> AnimeHistory = { id, episodeId, seenAt, _ -> +val animehistoryMapper: (Long, Long, Date?) -> AnimeHistory = { id, episodeId, seenAt -> AnimeHistory( id = id, episodeId = episodeId, @@ -13,7 +13,7 @@ val animehistoryMapper: (Long, Long, Date?, Date?) -> AnimeHistory = { id, episo } val animehistoryWithRelationsMapper: (Long, Long, Long, String, String?, Float, Date?) -> AnimeHistoryWithRelations = { - historyId, animeId, episodeId, title, thumbnailUrl, episodeNumber, seenAt -> + historyId, animeId, episodeId, title, thumbnailUrl, episodeNumber, seenAt -> AnimeHistoryWithRelations( id = historyId, episodeId = episodeId, @@ -21,6 +21,6 @@ val animehistoryWithRelationsMapper: (Long, Long, Long, String, String?, Float, title = title, thumbnailUrl = thumbnailUrl ?: "", episodeNumber = episodeNumber, - seenAt = seenAt + seenAt = seenAt, ) } diff --git a/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt index 3ea7a5d7d0..3f914ab090 100644 --- a/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/animehistory/AnimeHistoryRepositoryImpl.kt @@ -5,13 +5,15 @@ import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.data.anime.animeMapper import eu.kanade.data.episode.episodeMapper import eu.kanade.domain.anime.model.Anime +import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository import eu.kanade.domain.episode.model.Episode import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority class AnimeHistoryRepositoryImpl( - private val handler: AnimeDatabaseHandler + private val handler: AnimeDatabaseHandler, ) : AnimeHistoryRepository { override fun getHistory(query: String): PagingSource { @@ -20,7 +22,7 @@ class AnimeHistoryRepositoryImpl( transacter = { animehistoryViewQueries }, queryProvider = { limit, offset -> animehistoryViewQueries.animehistory(query, limit, offset, animehistoryWithRelationsMapper) - } + }, ) } @@ -67,7 +69,7 @@ class AnimeHistoryRepositoryImpl( try { handler.await { animehistoryQueries.resetAnimeHistoryById(historyId) } } catch (e: Exception) { - logcat(throwable = e) + logcat(LogPriority.ERROR, throwable = e) } } @@ -75,7 +77,7 @@ class AnimeHistoryRepositoryImpl( try { handler.await { animehistoryQueries.resetHistoryByAnimeId(animeId) } } catch (e: Exception) { - logcat(throwable = e) + logcat(LogPriority.ERROR, throwable = e) } } @@ -84,8 +86,21 @@ class AnimeHistoryRepositoryImpl( handler.await { animehistoryQueries.removeAllHistory() } true } catch (e: Exception) { - logcat(throwable = e) + logcat(LogPriority.ERROR, throwable = e) false } } + + override suspend fun upsertHistory(historyUpdate: AnimeHistoryUpdate) { + try { + handler.await { + animehistoryQueries.upsert( + historyUpdate.episodeId, + historyUpdate.seenAt, + ) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, throwable = e) + } + } } diff --git a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt index 58e3cb807f..fec80938e7 100644 --- a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt +++ b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceMapper.kt @@ -8,7 +8,7 @@ val animesourceMapper: (eu.kanade.tachiyomi.animesource.AnimeSource) -> AnimeSou source.id, source.lang, source.name, - false + false, ) } diff --git a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt index 031640277b..b3be39d687 100644 --- a/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/animesource/AnimeSourceRepositoryImpl.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.map class AnimeSourceRepositoryImpl( private val sourceManager: AnimeSourceManager, - private val handler: AnimeDatabaseHandler + private val handler: AnimeDatabaseHandler, ) : AnimeSourceRepository { override fun getSources(): Flow> { diff --git a/app/src/main/java/eu/kanade/data/chapter/ChapterRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/chapter/ChapterRepositoryImpl.kt new file mode 100644 index 0000000000..0d7ae610a4 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/chapter/ChapterRepositoryImpl.kt @@ -0,0 +1,36 @@ +package eu.kanade.data.chapter + +import eu.kanade.data.DatabaseHandler +import eu.kanade.data.toLong +import eu.kanade.domain.chapter.model.ChapterUpdate +import eu.kanade.domain.chapter.repository.ChapterRepository +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority + +class ChapterRepositoryImpl( + private val databaseHandler: DatabaseHandler, +) : ChapterRepository { + + override suspend fun update(chapterUpdate: ChapterUpdate) { + try { + databaseHandler.await { + chaptersQueries.update( + chapterUpdate.mangaId, + chapterUpdate.url, + chapterUpdate.name, + chapterUpdate.scanlator, + chapterUpdate.read?.toLong(), + chapterUpdate.bookmark?.toLong(), + chapterUpdate.lastPageRead, + chapterUpdate.chapterNumber?.toDouble(), + chapterUpdate.sourceOrder, + chapterUpdate.dateFetch, + chapterUpdate.dateUpload, + chapterId = chapterUpdate.id, + ) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } + } +} diff --git a/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt new file mode 100644 index 0000000000..0412f0151f --- /dev/null +++ b/app/src/main/java/eu/kanade/data/episode/EpisodeRepositoryImpl.kt @@ -0,0 +1,36 @@ +package eu.kanade.data.episode + +import eu.kanade.data.AnimeDatabaseHandler +import eu.kanade.data.toLong +import eu.kanade.domain.episode.model.EpisodeUpdate +import eu.kanade.domain.episode.repository.EpisodeRepository +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority + +class EpisodeRepositoryImpl( + private val databaseHandler: AnimeDatabaseHandler, +) : EpisodeRepository { + + override suspend fun update(episodeUpdate: EpisodeUpdate) { + try { + databaseHandler.await { + episodesQueries.update( + episodeUpdate.animeId, + episodeUpdate.url, + episodeUpdate.name, + episodeUpdate.scanlator, + episodeUpdate.seen?.toLong(), + episodeUpdate.bookmark?.toLong(), + episodeUpdate.lastSecondSeen, + episodeUpdate.episodeNumber?.toDouble(), + episodeUpdate.sourceOrder, + episodeUpdate.dateFetch, + episodeUpdate.dateUpload, + episodeId = episodeUpdate.id, + ) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } + } +} diff --git a/app/src/main/java/eu/kanade/data/history/HistoryMapper.kt b/app/src/main/java/eu/kanade/data/history/HistoryMapper.kt index e7ebf5cb49..8164c5e0b4 100644 --- a/app/src/main/java/eu/kanade/data/history/HistoryMapper.kt +++ b/app/src/main/java/eu/kanade/data/history/HistoryMapper.kt @@ -4,16 +4,17 @@ import eu.kanade.domain.history.model.History import eu.kanade.domain.history.model.HistoryWithRelations import java.util.Date -val historyMapper: (Long, Long, Date?, Date?) -> History = { id, chapterId, readAt, _ -> +val historyMapper: (Long, Long, Date?, Long) -> History = { id, chapterId, readAt, readDuration -> History( id = id, chapterId = chapterId, readAt = readAt, + readDuration = readDuration, ) } -val historyWithRelationsMapper: (Long, Long, Long, String, String?, Float, Date?) -> HistoryWithRelations = { - historyId, mangaId, chapterId, title, thumbnailUrl, chapterNumber, readAt -> +val historyWithRelationsMapper: (Long, Long, Long, String, String?, Float, Date?, Long) -> HistoryWithRelations = { + historyId, mangaId, chapterId, title, thumbnailUrl, chapterNumber, readAt, readDuration -> HistoryWithRelations( id = historyId, chapterId = chapterId, @@ -21,6 +22,7 @@ val historyWithRelationsMapper: (Long, Long, Long, String, String?, Float, Date? title = title, thumbnailUrl = thumbnailUrl ?: "", chapterNumber = chapterNumber, - readAt = readAt + readAt = readAt, + readDuration = readDuration, ) } diff --git a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt index 15d2d26332..1908963059 100644 --- a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt @@ -5,13 +5,15 @@ 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.HistoryUpdate 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 +import logcat.LogPriority class HistoryRepositoryImpl( - private val handler: DatabaseHandler + private val handler: DatabaseHandler, ) : HistoryRepository { override fun getHistory(query: String): PagingSource { @@ -20,7 +22,7 @@ class HistoryRepositoryImpl( transacter = { historyViewQueries }, queryProvider = { limit, offset -> historyViewQueries.history(query, limit, offset, historyWithRelationsMapper) - } + }, ) } @@ -67,7 +69,7 @@ class HistoryRepositoryImpl( try { handler.await { historyQueries.resetHistoryById(historyId) } } catch (e: Exception) { - logcat(throwable = e) + logcat(LogPriority.ERROR, throwable = e) } } @@ -75,7 +77,7 @@ class HistoryRepositoryImpl( try { handler.await { historyQueries.resetHistoryByMangaId(mangaId) } } catch (e: Exception) { - logcat(throwable = e) + logcat(LogPriority.ERROR, throwable = e) } } @@ -84,8 +86,22 @@ class HistoryRepositoryImpl( handler.await { historyQueries.removeAllHistory() } true } catch (e: Exception) { - logcat(throwable = e) + logcat(LogPriority.ERROR, throwable = e) false } } + + override suspend fun upsertHistory(historyUpdate: HistoryUpdate) { + try { + handler.await { + historyQueries.upsert( + historyUpdate.chapterId, + historyUpdate.readAt, + historyUpdate.sessionReadDuration, + ) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, throwable = e) + } + } } diff --git a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt index 137d2ec0df..70ff72e294 100644 --- a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt @@ -3,13 +3,25 @@ package eu.kanade.data.manga import eu.kanade.data.DatabaseHandler import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.repository.MangaRepository +import eu.kanade.tachiyomi.util.system.logcat import kotlinx.coroutines.flow.Flow +import logcat.LogPriority class MangaRepositoryImpl( - private val databaseHandler: DatabaseHandler + private val databaseHandler: DatabaseHandler, ) : MangaRepository { override fun getFavoritesBySourceId(sourceId: Long): Flow> { return databaseHandler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) } } + + override suspend fun resetViewerFlags(): Boolean { + return try { + databaseHandler.await { mangasQueries.resetViewerFlags() } + true + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + false + } + } } diff --git a/app/src/main/java/eu/kanade/data/source/SourceMapper.kt b/app/src/main/java/eu/kanade/data/source/SourceMapper.kt index ed4fd7f50a..e03e1c854f 100644 --- a/app/src/main/java/eu/kanade/data/source/SourceMapper.kt +++ b/app/src/main/java/eu/kanade/data/source/SourceMapper.kt @@ -8,7 +8,7 @@ val sourceMapper: (eu.kanade.tachiyomi.source.Source) -> Source = { source -> source.id, source.lang, source.name, - false + false, ) } diff --git a/app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt index 1dddc01e16..4db18e633e 100644 --- a/app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.map class SourceRepositoryImpl( private val sourceManager: SourceManager, - private val handler: DatabaseHandler + private val handler: DatabaseHandler, ) : SourceRepository { override fun getSources(): Flow> { diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 0a2ae73ef7..8accd2884b 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -3,15 +3,22 @@ package eu.kanade.domain import eu.kanade.data.anime.AnimeRepositoryImpl import eu.kanade.data.animehistory.AnimeHistoryRepositoryImpl import eu.kanade.data.animesource.AnimeSourceRepositoryImpl +import eu.kanade.data.chapter.ChapterRepositoryImpl +import eu.kanade.data.episode.EpisodeRepositoryImpl import eu.kanade.data.history.HistoryRepositoryImpl import eu.kanade.data.manga.MangaRepositoryImpl import eu.kanade.data.source.SourceRepositoryImpl import eu.kanade.domain.anime.repository.AnimeRepository +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionLanguages +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionUpdates +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensions import eu.kanade.domain.animehistory.interactor.DeleteAnimeHistoryTable import eu.kanade.domain.animehistory.interactor.GetAnimeHistory import eu.kanade.domain.animehistory.interactor.GetNextEpisodeForAnime import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryByAnimeId import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryById +import eu.kanade.domain.animehistory.interactor.UpsertAnimeHistory import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithFavoriteCount import eu.kanade.domain.animesource.interactor.GetEnabledAnimeSources @@ -19,13 +26,23 @@ import eu.kanade.domain.animesource.interactor.GetLanguagesWithAnimeSources import eu.kanade.domain.animesource.interactor.ToggleAnimeSource import eu.kanade.domain.animesource.interactor.ToggleAnimeSourcePin import eu.kanade.domain.animesource.repository.AnimeSourceRepository +import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.repository.ChapterRepository +import eu.kanade.domain.episode.interactor.UpdateEpisode +import eu.kanade.domain.episode.repository.EpisodeRepository +import eu.kanade.domain.extension.interactor.GetExtensionLanguages +import eu.kanade.domain.extension.interactor.GetExtensionSources +import eu.kanade.domain.extension.interactor.GetExtensionUpdates +import eu.kanade.domain.extension.interactor.GetExtensions 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.interactor.UpsertHistory import eu.kanade.domain.history.repository.HistoryRepository import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId +import eu.kanade.domain.manga.interactor.ResetViewerFlags import eu.kanade.domain.manga.repository.MangaRepository import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetLanguagesWithSources @@ -41,6 +58,7 @@ import uy.kohesive.injekt.api.addFactory import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.get import eu.kanade.domain.anime.interactor.GetFavoritesBySourceId as GetFavoritesBySourceIdAnime +import eu.kanade.domain.anime.interactor.ResetViewerFlags as ResetViewerFlagsAnime class DomainModule : InjektModule { @@ -48,20 +66,30 @@ class DomainModule : InjektModule { addSingletonFactory { AnimeRepositoryImpl(get()) } addFactory { GetFavoritesBySourceIdAnime(get()) } addFactory { GetNextEpisodeForAnime(get()) } + addFactory { ResetViewerFlagsAnime(get()) } + + addSingletonFactory { EpisodeRepositoryImpl(get()) } + addFactory { UpdateEpisode(get()) } addSingletonFactory { MangaRepositoryImpl(get()) } addFactory { GetFavoritesBySourceId(get()) } addFactory { GetNextChapterForManga(get()) } + addFactory { ResetViewerFlags(get()) } + + addSingletonFactory { ChapterRepositoryImpl(get()) } + addFactory { UpdateChapter(get()) } addSingletonFactory { AnimeHistoryRepositoryImpl(get()) } addFactory { DeleteAnimeHistoryTable(get()) } addFactory { GetAnimeHistory(get()) } + addFactory { UpsertAnimeHistory(get()) } addFactory { RemoveAnimeHistoryById(get()) } addFactory { RemoveAnimeHistoryByAnimeId(get()) } addSingletonFactory { HistoryRepositoryImpl(get()) } addFactory { DeleteHistoryTable(get()) } addFactory { GetHistory(get()) } + addFactory { UpsertHistory(get()) } addFactory { RemoveHistoryById(get()) } addFactory { RemoveHistoryByMangaId(get()) } @@ -72,6 +100,16 @@ class DomainModule : InjektModule { addFactory { ToggleAnimeSourcePin(get()) } addFactory { GetAnimeSourcesWithFavoriteCount(get(), get()) } + addFactory { GetAnimeExtensions(get(), get()) } + addFactory { GetAnimeExtensionSources(get()) } + addFactory { GetAnimeExtensionUpdates(get(), get()) } + addFactory { GetAnimeExtensionLanguages(get(), get()) } + + addFactory { GetExtensions(get(), get()) } + addFactory { GetExtensionSources(get()) } + addFactory { GetExtensionUpdates(get(), get()) } + addFactory { GetExtensionLanguages(get(), get()) } + addSingletonFactory { SourceRepositoryImpl(get(), get()) } addFactory { GetLanguagesWithSources(get(), get()) } addFactory { GetEnabledSources(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/GetFavoritesBySourceId.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/GetFavoritesBySourceId.kt index 90e73c9bc9..33e11b4134 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/GetFavoritesBySourceId.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/GetFavoritesBySourceId.kt @@ -5,7 +5,7 @@ import eu.kanade.domain.anime.repository.AnimeRepository import kotlinx.coroutines.flow.Flow class GetFavoritesBySourceId( - private val animeRepository: AnimeRepository + private val animeRepository: AnimeRepository, ) { fun subscribe(sourceId: Long): Flow> { diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/ResetViewerFlags.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/ResetViewerFlags.kt new file mode 100644 index 0000000000..6814d3c134 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/ResetViewerFlags.kt @@ -0,0 +1,11 @@ +package eu.kanade.domain.anime.interactor + +import eu.kanade.domain.anime.repository.AnimeRepository + +class ResetViewerFlags( + private val animeRepository: AnimeRepository, +) { + suspend fun await(): Boolean { + return animeRepository.resetViewerFlags() + } +} diff --git a/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt b/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt index 2b2d3ab792..286b16307e 100644 --- a/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt @@ -17,7 +17,7 @@ data class Anime( val genre: List?, val status: Long, val thumbnailUrl: String?, - val initialized: Boolean + val initialized: Boolean, ) { val sorting: Long diff --git a/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt b/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt index 8b645fc842..53c5776974 100644 --- a/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt +++ b/app/src/main/java/eu/kanade/domain/anime/repository/AnimeRepository.kt @@ -6,4 +6,6 @@ import kotlinx.coroutines.flow.Flow interface AnimeRepository { fun getFavoritesBySourceId(sourceId: Long): Flow> + + suspend fun resetViewerFlags(): Boolean } diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt new file mode 100644 index 0000000000..2ff2f63aa6 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt @@ -0,0 +1,30 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.core.util.asFlow +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class GetAnimeExtensionLanguages( + private val preferences: PreferencesHelper, + private val extensionManager: AnimeExtensionManager, +) { + fun subscribe(): Flow> { + return combine( + preferences.enabledLanguages().asFlow(), + extensionManager.getAvailableExtensionsObservable().asFlow(), + ) { enabledLanguage, availableExtensions -> + availableExtensions + .map { it.lang } + .distinct() + .sortedWith( + compareBy( + { it !in enabledLanguage }, + { LocaleHelper.getDisplayName(it) }, + ), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt new file mode 100644 index 0000000000..9475ef8eea --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt @@ -0,0 +1,32 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetAnimeExtensionSources( + private val preferences: PreferencesHelper, +) { + + fun subscribe(extension: AnimeExtension.Installed): Flow> { + val isMultiSource = extension.sources.size > 1 + val isMultiLangSingleSource = + isMultiSource && extension.sources.map { it.name }.distinct().size == 1 + + return preferences.disabledSources().asFlow().map { disabledSources -> + fun AnimeSource.isEnabled() = id.toString() !in disabledSources + + extension.sources + .map { source -> + AnimeExtensionSourceItem( + source = source, + enabled = source.isEnabled(), + labelAsName = isMultiSource && isMultiLangSingleSource.not(), + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionUpdates.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionUpdates.kt new file mode 100644 index 0000000000..b0d20a8e58 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionUpdates.kt @@ -0,0 +1,25 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.core.util.asFlow +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetAnimeExtensionUpdates( + private val preferences: PreferencesHelper, + private val extensionManager: AnimeExtensionManager, +) { + + fun subscribe(): Flow> { + val showNsfwSources = preferences.showNsfwSource().get() + + return extensionManager.getInstalledExtensionsObservable().asFlow() + .map { installed -> + installed + .filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensions.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensions.kt new file mode 100644 index 0000000000..79643de7f9 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensions.kt @@ -0,0 +1,48 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.core.util.asFlow +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +typealias ExtensionSegregation = Triple, List, List> + +class GetAnimeExtensions( + private val preferences: PreferencesHelper, + private val extensionManager: AnimeExtensionManager, +) { + + fun subscribe(): Flow { + val showNsfwSources = preferences.showNsfwSource().get() + + return combine( + preferences.enabledLanguages().asFlow(), + extensionManager.getInstalledExtensionsObservable().asFlow(), + extensionManager.getUntrustedExtensionsObservable().asFlow(), + extensionManager.getAvailableExtensionsObservable().asFlow(), + ) { _activeLanguages, _installed, _untrusted, _available -> + + val installed = _installed + .filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) } + .sortedWith( + compareBy { it.isObsolete.not() } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) + + val untrusted = _untrusted + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + + val available = _available + .filter { extension -> + _installed.none { it.pkgName == extension.pkgName } && + _untrusted.none { it.pkgName == extension.pkgName } && + extension.lang in _activeLanguages && + (showNsfwSources || extension.isNsfw.not()) + } + + Triple(installed, untrusted, available) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt index c88a8de052..066fba4e52 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/DeleteAnimeHistoryTable.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.animehistory.interactor import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository class DeleteAnimeHistoryTable( - private val repository: AnimeHistoryRepository + private val repository: AnimeHistoryRepository, ) { suspend fun await(): Boolean { diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt index 1b0a2044fb..af6e4cbfea 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetAnimeHistory.kt @@ -8,12 +8,12 @@ import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository import kotlinx.coroutines.flow.Flow class GetAnimeHistory( - private val repository: AnimeHistoryRepository + private val repository: AnimeHistoryRepository, ) { fun subscribe(query: String): Flow> { return Pager( - PagingConfig(pageSize = 25) + PagingConfig(pageSize = 25), ) { repository.getHistory(query) }.flow diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisodeForAnime.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisodeForAnime.kt index 07b9e63527..09dbc8dea7 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisodeForAnime.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/GetNextEpisodeForAnime.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository import eu.kanade.domain.episode.model.Episode class GetNextEpisodeForAnime( - private val repository: AnimeHistoryRepository + private val repository: AnimeHistoryRepository, ) { suspend fun await(animeId: Long, episodeId: Long): Episode? { diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryByAnimeId.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryByAnimeId.kt index b3874a26b3..e19072180d 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryByAnimeId.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryByAnimeId.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.animehistory.interactor import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository class RemoveAnimeHistoryByAnimeId( - private val repository: AnimeHistoryRepository + private val repository: AnimeHistoryRepository, ) { suspend fun await(animeId: Long) { diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryById.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryById.kt index 3e622d46db..4d4dec80f2 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryById.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/RemoveAnimeHistoryById.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository class RemoveAnimeHistoryById( - private val repository: AnimeHistoryRepository + private val repository: AnimeHistoryRepository, ) { suspend fun await(history: AnimeHistoryWithRelations) { diff --git a/app/src/main/java/eu/kanade/domain/animehistory/interactor/UpsertAnimeHistory.kt b/app/src/main/java/eu/kanade/domain/animehistory/interactor/UpsertAnimeHistory.kt new file mode 100644 index 0000000000..13e2291e0c --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animehistory/interactor/UpsertAnimeHistory.kt @@ -0,0 +1,13 @@ +package eu.kanade.domain.animehistory.interactor + +import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate +import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository + +class UpsertAnimeHistory( + private val animehistoryRepository: AnimeHistoryRepository, +) { + + suspend fun await(historyUpdate: AnimeHistoryUpdate) { + animehistoryRepository.upsertHistory(historyUpdate) + } +} diff --git a/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistory.kt b/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistory.kt index 4075a6ed70..56a861d7b1 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistory.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistory.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.animehistory.model import java.util.Date data class AnimeHistory( - val id: Long?, + val id: Long, val episodeId: Long, - val seenAt: Date? + val seenAt: Date?, ) diff --git a/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryUpdate.kt b/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryUpdate.kt new file mode 100644 index 0000000000..0c26337516 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryUpdate.kt @@ -0,0 +1,9 @@ +package eu.kanade.domain.animehistory.model + +import java.util.Date + +data class AnimeHistoryUpdate( + val episodeId: Long, + val seenAt: Date, + val sessionWatchDuration: Long, +) diff --git a/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryWithRelations.kt b/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryWithRelations.kt index 802f38d438..51ab05df9e 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryWithRelations.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/model/AnimeHistoryWithRelations.kt @@ -9,5 +9,5 @@ data class AnimeHistoryWithRelations( val title: String, val thumbnailUrl: String, val episodeNumber: Float, - val seenAt: Date? + val seenAt: Date?, ) diff --git a/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt b/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt index 5c1da2b805..cc8c03550f 100644 --- a/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt +++ b/app/src/main/java/eu/kanade/domain/animehistory/repository/AnimeHistoryRepository.kt @@ -1,6 +1,7 @@ package eu.kanade.domain.animehistory.repository import androidx.paging.PagingSource +import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.episode.model.Episode @@ -15,4 +16,6 @@ interface AnimeHistoryRepository { suspend fun resetHistoryByAnimeId(animeId: Long) suspend fun deleteAllHistory(): Boolean + + suspend fun upsertHistory(historyUpdate: AnimeHistoryUpdate) } diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt index 2f8d7778fe..15c6bde7df 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetAnimeSourcesWithFavoriteCount.kt @@ -12,14 +12,14 @@ import java.util.Locale class GetAnimeSourcesWithFavoriteCount( private val repository: AnimeSourceRepository, - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { fun subscribe(): Flow>> { return combine( preferences.migrationSortingDirection().asFlow(), preferences.migrationSortingMode().asFlow(), - repository.getSourcesWithFavoriteCount() + repository.getSourcesWithFavoriteCount(), ) { direction, mode, list -> list.sortedWith(sortFn(direction, mode)) } @@ -27,7 +27,7 @@ class GetAnimeSourcesWithFavoriteCount( private fun sortFn( direction: SetMigrateSorting.Direction, - sorting: SetMigrateSorting.Mode + sorting: SetMigrateSorting.Mode, ): java.util.Comparator> { val locale = Locale.getDefault() val collator = Collator.getInstance(locale).apply { diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt index c2a7ff2969..ca209a844c 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/GetLanguagesWithAnimeSources.kt @@ -16,19 +16,19 @@ class GetLanguagesWithAnimeSources( return combine( preferences.enabledLanguages().asFlow(), preferences.disabledSources().asFlow(), - repository.getOnlineSources() + repository.getOnlineSources(), ) { enabledLanguage, disabledSource, onlineSources -> val sortedSources = onlineSources.sortedWith( compareBy { it.id.toString() in disabledSource } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, ) sortedSources.groupBy { it.lang } .toSortedMap( compareBy( { it !in enabledLanguage }, - { LocaleHelper.getDisplayName(it) } - ) + { LocaleHelper.getDisplayName(it) }, + ), ) } } diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt index f4397e3260..e33a1e7566 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSource.kt @@ -6,15 +6,18 @@ import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.util.preference.plusAssign class ToggleAnimeSource( - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { - fun await(source: AnimeSource) { - val isEnabled = source.id.toString() !in preferences.disabledAnimeSources().get() - if (isEnabled) { - preferences.disabledAnimeSources() += source.id.toString() + fun await(source: AnimeSource, enable: Boolean = source.id.toString() in preferences.disabledAnimeSources().get()) { + await(source.id, enable) + } + + fun await(sourceId: Long, enable: Boolean = sourceId.toString() in preferences.disabledAnimeSources().get()) { + if (enable) { + preferences.disabledAnimeSources() -= sourceId.toString() } else { - preferences.disabledAnimeSources() -= source.id.toString() + preferences.disabledAnimeSources() += sourceId.toString() } } } diff --git a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt index 683198cec5..d07d8d8913 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/interactor/ToggleAnimeSourcePin.kt @@ -6,11 +6,11 @@ import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.util.preference.plusAssign class ToggleAnimeSourcePin( - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { fun await(source: AnimeSource) { - val isPinned = source.id.toString() in preferences.pinnedSources().get() + val isPinned = source.id.toString() in preferences.pinnedAnimeSources().get() if (isPinned) { preferences.pinnedAnimeSources() -= source.id.toString() } else { diff --git a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt index 7d37529179..ced438f4ef 100644 --- a/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt +++ b/app/src/main/java/eu/kanade/domain/animesource/model/AnimeSource.kt @@ -13,7 +13,7 @@ data class AnimeSource( val name: String, val supportsLatest: Boolean, val pin: Pins = Pins.unpinned, - val isUsedLast: Boolean = false + val isUsedLast: Boolean = false, ) { val nameWithLanguage: String diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/UpdateChapter.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/UpdateChapter.kt new file mode 100644 index 0000000000..2d9d0d052a --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/UpdateChapter.kt @@ -0,0 +1,13 @@ +package eu.kanade.domain.chapter.interactor + +import eu.kanade.domain.chapter.model.ChapterUpdate +import eu.kanade.domain.chapter.repository.ChapterRepository + +class UpdateChapter( + private val chapterRepository: ChapterRepository, +) { + + suspend fun await(chapterUpdate: ChapterUpdate) { + chapterRepository.update(chapterUpdate) + } +} 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 index 6eff7c580b..27ed9a46f9 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/model/Chapter.kt @@ -12,5 +12,5 @@ data class Chapter( val name: String, val dateUpload: Long, val chapterNumber: Float, - val scanlator: String? + val scanlator: String?, ) diff --git a/app/src/main/java/eu/kanade/domain/chapter/model/ChapterUpdate.kt b/app/src/main/java/eu/kanade/domain/chapter/model/ChapterUpdate.kt new file mode 100644 index 0000000000..2c9042c477 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/chapter/model/ChapterUpdate.kt @@ -0,0 +1,16 @@ +package eu.kanade.domain.chapter.model + +data class ChapterUpdate( + val id: Long, + val mangaId: Long? = null, + val read: Boolean? = null, + val bookmark: Boolean? = null, + val lastPageRead: Long? = null, + val dateFetch: Long? = null, + val sourceOrder: Long? = null, + val url: String? = null, + val name: String? = null, + val dateUpload: Long? = null, + val chapterNumber: Float? = null, + val scanlator: String? = null, +) diff --git a/app/src/main/java/eu/kanade/domain/chapter/repository/ChapterRepository.kt b/app/src/main/java/eu/kanade/domain/chapter/repository/ChapterRepository.kt new file mode 100644 index 0000000000..9f3bb73d74 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/chapter/repository/ChapterRepository.kt @@ -0,0 +1,8 @@ +package eu.kanade.domain.chapter.repository + +import eu.kanade.domain.chapter.model.ChapterUpdate + +interface ChapterRepository { + + suspend fun update(chapterUpdate: ChapterUpdate) +} diff --git a/app/src/main/java/eu/kanade/domain/episode/interactor/UpdateEpisode.kt b/app/src/main/java/eu/kanade/domain/episode/interactor/UpdateEpisode.kt new file mode 100644 index 0000000000..196fd0796b --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/episode/interactor/UpdateEpisode.kt @@ -0,0 +1,13 @@ +package eu.kanade.domain.episode.interactor + +import eu.kanade.domain.episode.model.EpisodeUpdate +import eu.kanade.domain.episode.repository.EpisodeRepository + +class UpdateEpisode( + private val episodeRepository: EpisodeRepository, +) { + + suspend fun await(episodeUpdate: EpisodeUpdate) { + episodeRepository.update(episodeUpdate) + } +} diff --git a/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt b/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt index 5ca06031ec..a01b30e09f 100644 --- a/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt +++ b/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt @@ -13,5 +13,5 @@ data class Episode( val name: String, val dateUpload: Long, val episodeNumber: Float, - val scanlator: String? + val scanlator: String?, ) diff --git a/app/src/main/java/eu/kanade/domain/episode/model/EpisodeUpdate.kt b/app/src/main/java/eu/kanade/domain/episode/model/EpisodeUpdate.kt new file mode 100644 index 0000000000..3a38833cf9 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/episode/model/EpisodeUpdate.kt @@ -0,0 +1,16 @@ +package eu.kanade.domain.episode.model + +data class EpisodeUpdate( + val id: Long, + val animeId: Long? = null, + val seen: Boolean? = null, + val bookmark: Boolean? = null, + val lastSecondSeen: Long? = null, + val dateFetch: Long? = null, + val sourceOrder: Long? = null, + val url: String? = null, + val name: String? = null, + val dateUpload: Long? = null, + val episodeNumber: Float? = null, + val scanlator: String? = null, +) diff --git a/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt b/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt new file mode 100644 index 0000000000..a311b0dbc8 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/episode/repository/EpisodeRepository.kt @@ -0,0 +1,8 @@ +package eu.kanade.domain.episode.repository + +import eu.kanade.domain.episode.model.EpisodeUpdate + +interface EpisodeRepository { + + suspend fun update(episodeUpdate: EpisodeUpdate) +} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionLanguages.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionLanguages.kt new file mode 100644 index 0000000000..7bda921771 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionLanguages.kt @@ -0,0 +1,30 @@ +package eu.kanade.domain.extension.interactor + +import eu.kanade.core.util.asFlow +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class GetExtensionLanguages( + private val preferences: PreferencesHelper, + private val extensionManager: ExtensionManager, +) { + fun subscribe(): Flow> { + return combine( + preferences.enabledLanguages().asFlow(), + extensionManager.getAvailableExtensionsObservable().asFlow(), + ) { enabledLanguage, availableExtensions -> + availableExtensions + .map { it.lang } + .distinct() + .sortedWith( + compareBy( + { it !in enabledLanguage }, + { LocaleHelper.getDisplayName(it) }, + ), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt new file mode 100644 index 0000000000..a2acb4bed6 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt @@ -0,0 +1,32 @@ +package eu.kanade.domain.extension.interactor + +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetExtensionSources( + private val preferences: PreferencesHelper, +) { + + fun subscribe(extension: Extension.Installed): Flow> { + val isMultiSource = extension.sources.size > 1 + val isMultiLangSingleSource = + isMultiSource && extension.sources.map { it.name }.distinct().size == 1 + + return preferences.disabledSources().asFlow().map { disabledSources -> + fun Source.isEnabled() = id.toString() !in disabledSources + + extension.sources + .map { source -> + ExtensionSourceItem( + source = source, + enabled = source.isEnabled(), + labelAsName = isMultiSource && isMultiLangSingleSource.not(), + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt new file mode 100644 index 0000000000..96373f9b4b --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt @@ -0,0 +1,25 @@ +package eu.kanade.domain.extension.interactor + +import eu.kanade.core.util.asFlow +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.model.Extension +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetExtensionUpdates( + private val preferences: PreferencesHelper, + private val extensionManager: ExtensionManager, +) { + + fun subscribe(): Flow> { + val showNsfwSources = preferences.showNsfwSource().get() + + return extensionManager.getInstalledExtensionsObservable().asFlow() + .map { installed -> + installed + .filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt new file mode 100644 index 0000000000..cdd3ba1ab0 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt @@ -0,0 +1,48 @@ +package eu.kanade.domain.extension.interactor + +import eu.kanade.core.util.asFlow +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.model.Extension +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +typealias ExtensionSegregation = Triple, List, List> + +class GetExtensions( + private val preferences: PreferencesHelper, + private val extensionManager: ExtensionManager, +) { + + fun subscribe(): Flow { + val showNsfwSources = preferences.showNsfwSource().get() + + return combine( + preferences.enabledLanguages().asFlow(), + extensionManager.getInstalledExtensionsObservable().asFlow(), + extensionManager.getUntrustedExtensionsObservable().asFlow(), + extensionManager.getAvailableExtensionsObservable().asFlow(), + ) { _activeLanguages, _installed, _untrusted, _available -> + + val installed = _installed + .filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) } + .sortedWith( + compareBy { it.isObsolete.not() } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) + + val untrusted = _untrusted + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + + val available = _available + .filter { extension -> + _installed.none { it.pkgName == extension.pkgName } && + _untrusted.none { it.pkgName == extension.pkgName } && + extension.lang in _activeLanguages && + (showNsfwSources || extension.isNsfw.not()) + } + + Triple(installed, untrusted, available) + } + } +} 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 index bebf1209d3..a8e10cbf51 100644 --- a/app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt +++ b/app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.history.interactor import eu.kanade.domain.history.repository.HistoryRepository class DeleteHistoryTable( - private val repository: HistoryRepository + private val repository: HistoryRepository, ) { suspend fun await(): Boolean { 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 index d2f8302b75..1db219ed37 100644 --- a/app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt +++ b/app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt @@ -8,12 +8,12 @@ import eu.kanade.domain.history.repository.HistoryRepository import kotlinx.coroutines.flow.Flow class GetHistory( - private val repository: HistoryRepository + private val repository: HistoryRepository, ) { fun subscribe(query: String): Flow> { return Pager( - PagingConfig(pageSize = 25) + 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 index 477408ca3a..4ed35a347f 100644 --- a/app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt +++ b/app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.chapter.model.Chapter import eu.kanade.domain.history.repository.HistoryRepository class GetNextChapterForManga( - private val repository: HistoryRepository + private val repository: HistoryRepository, ) { suspend fun await(mangaId: Long, chapterId: Long): Chapter? { 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 index 93012c266b..4eb15568c5 100644 --- a/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt +++ b/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt @@ -4,7 +4,7 @@ import eu.kanade.domain.history.model.HistoryWithRelations import eu.kanade.domain.history.repository.HistoryRepository class RemoveHistoryById( - private val repository: HistoryRepository + private val repository: HistoryRepository, ) { suspend fun await(history: HistoryWithRelations) { 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 index f32fa5f7b2..4c35584563 100644 --- a/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt +++ b/app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.history.interactor import eu.kanade.domain.history.repository.HistoryRepository class RemoveHistoryByMangaId( - private val repository: HistoryRepository + private val repository: HistoryRepository, ) { suspend fun await(mangaId: Long) { diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/UpsertHistory.kt b/app/src/main/java/eu/kanade/domain/history/interactor/UpsertHistory.kt new file mode 100644 index 0000000000..31c46b2999 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/history/interactor/UpsertHistory.kt @@ -0,0 +1,13 @@ +package eu.kanade.domain.history.interactor + +import eu.kanade.domain.history.model.HistoryUpdate +import eu.kanade.domain.history.repository.HistoryRepository + +class UpsertHistory( + private val historyRepository: HistoryRepository, +) { + + suspend fun await(historyUpdate: HistoryUpdate) { + historyRepository.upsertHistory(historyUpdate) + } +} 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 index 58c1c985e6..b8a1cf3c10 100644 --- a/app/src/main/java/eu/kanade/domain/history/model/History.kt +++ b/app/src/main/java/eu/kanade/domain/history/model/History.kt @@ -3,7 +3,8 @@ package eu.kanade.domain.history.model import java.util.Date data class History( - val id: Long?, + val id: Long, val chapterId: Long, - val readAt: Date? + val readAt: Date?, + val readDuration: Long, ) diff --git a/app/src/main/java/eu/kanade/domain/history/model/HistoryUpdate.kt b/app/src/main/java/eu/kanade/domain/history/model/HistoryUpdate.kt new file mode 100644 index 0000000000..8cd7f5a2ef --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/history/model/HistoryUpdate.kt @@ -0,0 +1,9 @@ +package eu.kanade.domain.history.model + +import java.util.Date + +data class HistoryUpdate( + val chapterId: Long, + val readAt: Date, + val sessionReadDuration: Long, +) 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 index 6f5a8e1fc0..2871b80bec 100644 --- a/app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt +++ b/app/src/main/java/eu/kanade/domain/history/model/HistoryWithRelations.kt @@ -9,5 +9,6 @@ data class HistoryWithRelations( val title: String, val thumbnailUrl: String, val chapterNumber: Float, - val readAt: Date? + val readAt: Date?, + val readDuration: Long, ) 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 index 38e0f4192f..b50c8c6405 100644 --- a/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt +++ b/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt @@ -2,6 +2,7 @@ package eu.kanade.domain.history.repository import androidx.paging.PagingSource import eu.kanade.domain.chapter.model.Chapter +import eu.kanade.domain.history.model.HistoryUpdate import eu.kanade.domain.history.model.HistoryWithRelations interface HistoryRepository { @@ -15,4 +16,6 @@ interface HistoryRepository { suspend fun resetHistoryByMangaId(mangaId: Long) suspend fun deleteAllHistory(): Boolean + + suspend fun upsertHistory(historyUpdate: HistoryUpdate) } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetFavoritesBySourceId.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetFavoritesBySourceId.kt index 9fd42effad..8489d4d57d 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/GetFavoritesBySourceId.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetFavoritesBySourceId.kt @@ -5,7 +5,7 @@ import eu.kanade.domain.manga.repository.MangaRepository import kotlinx.coroutines.flow.Flow class GetFavoritesBySourceId( - private val mangaRepository: MangaRepository + private val mangaRepository: MangaRepository, ) { fun subscribe(sourceId: Long): Flow> { diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/ResetViewerFlags.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/ResetViewerFlags.kt new file mode 100644 index 0000000000..13b766af12 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/ResetViewerFlags.kt @@ -0,0 +1,11 @@ +package eu.kanade.domain.manga.interactor + +import eu.kanade.domain.manga.repository.MangaRepository + +class ResetViewerFlags( + private val mangaRepository: MangaRepository, +) { + suspend fun await(): Boolean { + return mangaRepository.resetViewerFlags() + } +} 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 index 9226c3f612..bed5f1d61b 100644 --- a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt @@ -17,7 +17,7 @@ data class Manga( val genre: List?, val status: Long, val thumbnailUrl: String?, - val initialized: Boolean + val initialized: Boolean, ) { val sorting: Long diff --git a/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt b/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt index 8fb60a78a1..0dcf680822 100644 --- a/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt +++ b/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt @@ -6,4 +6,6 @@ import kotlinx.coroutines.flow.Flow interface MangaRepository { fun getFavoritesBySourceId(sourceId: Long): Flow> + + suspend fun resetViewerFlags(): Boolean } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetLanguagesWithSources.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetLanguagesWithSources.kt index 1c157b5c11..3feef0a3a1 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/GetLanguagesWithSources.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetLanguagesWithSources.kt @@ -16,19 +16,19 @@ class GetLanguagesWithSources( return combine( preferences.enabledLanguages().asFlow(), preferences.disabledSources().asFlow(), - repository.getOnlineSources() + repository.getOnlineSources(), ) { enabledLanguage, disabledSource, onlineSources -> val sortedSources = onlineSources.sortedWith( compareBy { it.id.toString() in disabledSource } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, ) sortedSources.groupBy { it.lang } .toSortedMap( compareBy( { it !in enabledLanguage }, - { LocaleHelper.getDisplayName(it) } - ) + { LocaleHelper.getDisplayName(it) }, + ), ) } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourcesWithFavoriteCount.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourcesWithFavoriteCount.kt index a290091f64..e88012cada 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourcesWithFavoriteCount.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourcesWithFavoriteCount.kt @@ -11,14 +11,14 @@ import java.util.Locale class GetSourcesWithFavoriteCount( private val repository: SourceRepository, - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { fun subscribe(): Flow>> { return combine( preferences.migrationSortingDirection().asFlow(), preferences.migrationSortingMode().asFlow(), - repository.getSourcesWithFavoriteCount() + repository.getSourcesWithFavoriteCount(), ) { direction, mode, list -> list.sortedWith(sortFn(direction, mode)) } @@ -26,7 +26,7 @@ class GetSourcesWithFavoriteCount( private fun sortFn( direction: SetMigrateSorting.Direction, - sorting: SetMigrateSorting.Mode + sorting: SetMigrateSorting.Mode, ): java.util.Comparator> { val locale = Locale.getDefault() val collator = Collator.getInstance(locale).apply { diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/SetMigrateSorting.kt b/app/src/main/java/eu/kanade/domain/source/interactor/SetMigrateSorting.kt index 8728a9a54c..c93c56ee13 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/SetMigrateSorting.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/SetMigrateSorting.kt @@ -3,7 +3,7 @@ package eu.kanade.domain.source.interactor import eu.kanade.tachiyomi.data.preference.PreferencesHelper class SetMigrateSorting( - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { fun await(mode: Mode, isAscending: Boolean) { diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleLanguage.kt b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleLanguage.kt index da373e829e..508bc7335b 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleLanguage.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleLanguage.kt @@ -5,12 +5,12 @@ import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.util.preference.plusAssign class ToggleLanguage( - val preferences: PreferencesHelper + val preferences: PreferencesHelper, ) { fun await(language: String) { - val isEnabled = language in preferences.enabledLanguages().get() - if (isEnabled) { + val enabled = language in preferences.enabledLanguages().get() + if (enabled) { preferences.enabledLanguages() -= language } else { preferences.enabledLanguages() += language diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSource.kt b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSource.kt index 8c9296d129..585d20b993 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSource.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSource.kt @@ -6,15 +6,18 @@ import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.util.preference.plusAssign class ToggleSource( - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { - fun await(source: Source) { - val isEnabled = source.id.toString() !in preferences.disabledSources().get() - if (isEnabled) { - preferences.disabledSources() += source.id.toString() + fun await(source: Source, enable: Boolean = source.id.toString() in preferences.disabledSources().get()) { + await(source.id, enable) + } + + fun await(sourceId: Long, enable: Boolean = sourceId.toString() in preferences.disabledSources().get()) { + if (enable) { + preferences.disabledSources() -= sourceId.toString() } else { - preferences.disabledSources() -= source.id.toString() + preferences.disabledSources() += sourceId.toString() } } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt index 5a5e92df4a..d7229ab468 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/ToggleSourcePin.kt @@ -6,7 +6,7 @@ import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.util.preference.plusAssign class ToggleSourcePin( - private val preferences: PreferencesHelper + private val preferences: PreferencesHelper, ) { fun await(source: Source) { diff --git a/app/src/main/java/eu/kanade/domain/source/model/Source.kt b/app/src/main/java/eu/kanade/domain/source/model/Source.kt index 0f9d429ad3..00b494ba48 100644 --- a/app/src/main/java/eu/kanade/domain/source/model/Source.kt +++ b/app/src/main/java/eu/kanade/domain/source/model/Source.kt @@ -13,7 +13,7 @@ data class Source( val name: String, val supportsLatest: Boolean, val pin: Pins = Pins.unpinned, - val isUsedLast: Boolean = false + val isUsedLast: Boolean = false, ) { val nameWithLanguage: String diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/BaseAnimeListItem.kt b/app/src/main/java/eu/kanade/presentation/anime/components/BaseAnimeListItem.kt index ea4bb46a00..e9b0110f95 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/BaseAnimeListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/BaseAnimeListItem.kt @@ -33,7 +33,7 @@ fun BaseAnimeListItem( .clickable(onClick = onClickItem) .height(56.dp) .padding(horizontal = horizontalPadding), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { cover() content() @@ -47,7 +47,7 @@ private val defaultCover: @Composable RowScope.(Anime, () -> Unit) -> Unit = { m .padding(vertical = 8.dp) .clickable(onClick = onClick) .fillMaxHeight(), - data = manga.thumbnailUrl + data = manga.thumbnailUrl, ) } @@ -59,7 +59,7 @@ private val defaultContent: @Composable RowScope.(Anime) -> Unit = { .padding(start = horizontalPadding), overflow = TextOverflow.Ellipsis, maxLines = 1, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt index d1401ec916..790b659cfd 100644 --- a/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/animehistory/HistoryScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars 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 @@ -27,6 +26,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -44,7 +44,10 @@ import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.recent.animehistory.AnimeHistoryPresenter @@ -88,7 +91,7 @@ fun HistoryContent( onClickResume: (AnimeHistoryWithRelations) -> Unit, onClickDelete: (AnimeHistoryWithRelations, Boolean) -> Unit, preferences: PreferencesHelper = Injekt.get(), - nestedScroll: NestedScrollConnection + nestedScroll: NestedScrollConnection, ) { if (history.loadState.refresh is LoadState.Loading) { LoadingScreen() @@ -101,13 +104,13 @@ fun HistoryContent( val relativeTime: Int = remember { preferences.relativeTime().get() } val dateFormat: DateFormat = remember { preferences.dateFormat() } - val (removeState, setRemoveState) = remember { mutableStateOf(null) } + var removeState by remember { mutableStateOf(null) } val scrollState = rememberLazyListState() - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier .nestedScroll(nestedScroll), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, state = scrollState, ) { items(history) { item -> @@ -118,7 +121,7 @@ fun HistoryContent( .animateItemPlacement(), date = item.date, relativeTime = relativeTime, - dateFormat = dateFormat + dateFormat = dateFormat, ) } is HistoryUiModel.Item -> { @@ -128,7 +131,7 @@ fun HistoryContent( history = value, onClickCover = { onClickCover(value) }, onClickResume = { onClickResume(value) }, - onClickDelete = { setRemoveState(value) }, + onClickDelete = { removeState = value }, ) } null -> {} @@ -139,10 +142,10 @@ fun HistoryContent( if (removeState != null) { RemoveHistoryDialog( onPositive = { all -> - onClickDelete(removeState, all) - setRemoveState(null) + onClickDelete(removeState!!, all) + removeState = null }, - onNegative = { setRemoveState(null) } + onNegative = { removeState = null }, ) } } @@ -160,12 +163,12 @@ fun HistoryHeader( text = date.toRelativeString( LocalContext.current, relativeTime, - dateFormat + dateFormat, ), style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, - ) + ), ) } @@ -200,7 +203,7 @@ fun HistoryItem( text = history.title, maxLines = 2, overflow = TextOverflow.Ellipsis, - style = textStyle.copy(fontWeight = FontWeight.SemiBold) + style = textStyle.copy(fontWeight = FontWeight.SemiBold), ) Row { Text( @@ -222,7 +225,7 @@ fun HistoryItem( IconButton(onClick = onClickDelete) { Icon( imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(id = R.string.action_delete), + contentDescription = stringResource(R.string.action_delete), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -232,17 +235,17 @@ fun HistoryItem( @Composable fun RemoveHistoryDialog( onPositive: (Boolean) -> Unit, - onNegative: () -> Unit + onNegative: () -> Unit, ) { - val (removeEverything, removeEverythingState) = remember { mutableStateOf(false) } + var removeEverything by remember { mutableStateOf(false) } AlertDialog( title = { - Text(text = stringResource(id = R.string.action_remove)) + Text(text = stringResource(R.string.action_remove)) }, text = { Column { - Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description_anime)) + Text(text = stringResource(R.string.dialog_with_checkbox_remove_description_anime)) Row( modifier = Modifier .padding(top = 16.dp) @@ -250,9 +253,9 @@ fun RemoveHistoryDialog( interactionSource = remember { MutableInteractionSource() }, indication = null, value = removeEverything, - onValueChange = removeEverythingState + onValueChange = { removeEverything = it }, ), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = removeEverything, @@ -260,7 +263,7 @@ fun RemoveHistoryDialog( ) Text( modifier = Modifier.padding(start = 4.dp), - text = stringResource(id = R.string.dialog_with_checkbox_reset_anime) + text = stringResource(R.string.dialog_with_checkbox_reset_anime), ) } } @@ -268,12 +271,12 @@ fun RemoveHistoryDialog( onDismissRequest = onNegative, confirmButton = { TextButton(onClick = { onPositive(removeEverything) }) { - Text(text = stringResource(id = R.string.action_remove)) + Text(text = stringResource(R.string.action_remove)) } }, dismissButton = { TextButton(onClick = onNegative) { - Text(text = stringResource(id = R.string.action_cancel)) + Text(text = stringResource(R.string.action_cancel)) } }, ) diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt new file mode 100644 index 0000000000..3e66c7c761 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt @@ -0,0 +1,330 @@ +package eu.kanade.presentation.browse + +import android.util.DisplayMetrics +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.runtime.setValue +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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.DIVIDER_ALPHA +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionDetailsPresenter +import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun AnimeExtensionDetailsScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: AnimeExtensionDetailsPresenter, + onClickUninstall: () -> Unit, + onClickAppInfo: () -> Unit, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickSource: (sourceId: Long) -> Unit, +) { + val extension = presenter.extension + + if (extension == null) { + EmptyScreen(textResource = R.string.empty_screen) + return + } + + val sources by presenter.sourcesState.collectAsState() + + var showNsfwWarning by remember { mutableStateOf(false) } + + ScrollbarLazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + when { + extension.isUnofficial -> + item { + WarningBanner(R.string.unofficial_extension_message_aniyomi) + } + extension.isObsolete -> + item { + WarningBanner(R.string.obsolete_extension_message) + } + } + + item { + DetailsHeader( + extension = extension, + onClickUninstall = onClickUninstall, + onClickAppInfo = onClickAppInfo, + onClickAgeRating = { + showNsfwWarning = true + }, + ) + } + + items( + items = sources, + key = { it.source.id }, + ) { source -> + SourceSwitchPreference( + modifier = Modifier.animateItemPlacement(), + source = source, + onClickSourcePreferences = onClickSourcePreferences, + onClickSource = onClickSource, + ) + } + } + if (showNsfwWarning) { + NsfwWarningDialog( + onClickConfirm = { + showNsfwWarning = false + }, + ) + } +} + +@Composable +private fun WarningBanner(@StringRes textRes: Int) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.error) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(textRes), + color = MaterialTheme.colorScheme.onError, + ) + } +} + +@Composable +private fun DetailsHeader( + extension: AnimeExtension, + onClickAgeRating: () -> Unit, + onClickUninstall: () -> Unit, + onClickAppInfo: () -> Unit, +) { + val context = LocalContext.current + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = 16.dp, + bottom = 8.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ExtensionIcon( + modifier = Modifier + .size(112.dp), + extension = extension, + density = DisplayMetrics.DENSITY_XXXHIGH, + ) + + Text( + text = extension.name, + style = MaterialTheme.typography.headlineSmall, + ) + + val strippedPkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") + + Text( + text = strippedPkgName, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = horizontalPadding * 2, + vertical = 8.dp, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + InfoText( + primaryText = extension.versionName, + secondaryText = stringResource(R.string.ext_info_version), + ) + + InfoDivider() + + InfoText( + primaryText = LocaleHelper.getSourceDisplayName(extension.lang, context), + secondaryText = stringResource(R.string.ext_info_language), + ) + + if (extension.isNsfw) { + InfoDivider() + + InfoText( + primaryText = stringResource(R.string.ext_nsfw_short), + primaryTextStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium, + ), + secondaryText = stringResource(R.string.ext_info_age_rating), + onClick = onClickAgeRating, + ) + } + } + + Row( + modifier = Modifier.padding( + start = horizontalPadding, + end = horizontalPadding, + top = 8.dp, + bottom = 16.dp, + ), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onClickUninstall, + ) { + Text(stringResource(R.string.ext_uninstall)) + } + + Spacer(Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onClickAppInfo, + ) { + Text( + text = stringResource(R.string.ext_app_info), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + Divider() + } +} + +@Composable +private fun InfoText( + primaryText: String, + primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge, + secondaryText: String, + onClick: (() -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + + val modifier = if (onClick != null) { + Modifier.clickable(interactionSource, indication = null) { onClick() } + } else Modifier + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = primaryText, + style = primaryTextStyle, + ) + + Text( + text = secondaryText + if (onClick != null) " ⓘ" else "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } +} + +@Composable +private fun InfoDivider() { + Divider( + modifier = Modifier + .height(20.dp) + .width(1.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA), + ) +} + +@Composable +private fun SourceSwitchPreference( + modifier: Modifier = Modifier, + source: AnimeExtensionSourceItem, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickSource: (sourceId: Long) -> Unit, +) { + val context = LocalContext.current + + PreferenceRow( + modifier = modifier, + title = if (source.labelAsName) { + source.source.toString() + } else { + LocaleHelper.getSourceDisplayName(source.source.lang, context) + }, + onClick = { onClickSource(source.source.id) }, + action = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (source.source is ConfigurableAnimeSource) { + IconButton(onClick = { onClickSourcePreferences(source.source.id) }) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.label_settings), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Switch(checked = source.enabled, onCheckedChange = null) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionLangFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionLangFilterScreen.kt new file mode 100644 index 0000000000..196e87abc5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionLangFilterScreen.kt @@ -0,0 +1,68 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionFilterPresenter +import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionFilterState +import eu.kanade.tachiyomi.ui.browse.animeextension.FilterUiModel + +@Composable +fun AnimeExtensionFilterScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: AnimeExtensionFilterPresenter, + onClickLang: (String) -> Unit, +) { + val state by presenter.state.collectAsState() + + when (state) { + is ExtensionFilterState.Loading -> LoadingScreen() + is ExtensionFilterState.Error -> Text(text = (state as ExtensionFilterState.Error).error.message!!) + is ExtensionFilterState.Success -> + SourceFilterContent( + nestedScrollInterop = nestedScrollInterop, + items = (state as ExtensionFilterState.Success).models, + onClickLang = onClickLang, + ) + } +} + +@Composable +fun SourceFilterContent( + nestedScrollInterop: NestedScrollConnection, + items: List, + onClickLang: (String) -> Unit, +) { + if (items.isEmpty()) { + EmptyScreen(textResource = R.string.empty_screen) + return + } + + LazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + items( + items = items, + ) { model -> + ExtensionFilterItem( + modifier = Modifier.animateItemPlacement(), + lang = model.lang, + enabled = model.enabled, + onClickItem = onClickLang, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionsScreen.kt new file mode 100644 index 0000000000..858ab4ccc1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionsScreen.kt @@ -0,0 +1,360 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import eu.kanade.presentation.browse.components.BaseBrowseItem +import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.components.SwipeRefreshIndicator +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionsPresenter +import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionState +import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionUiModel +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun AnimeExtensionScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: AnimeExtensionsPresenter, + onLongClickItem: (AnimeExtension) -> Unit, + onClickItemCancel: (AnimeExtension) -> Unit, + onInstallExtension: (AnimeExtension.Available) -> Unit, + onUninstallExtension: (AnimeExtension) -> Unit, + onUpdateExtension: (AnimeExtension.Installed) -> Unit, + onTrustExtension: (AnimeExtension.Untrusted) -> Unit, + onOpenExtension: (AnimeExtension.Installed) -> Unit, + onClickUpdateAll: () -> Unit, + onRefresh: () -> Unit, + onLaunched: () -> Unit, +) { + val state by presenter.state.collectAsState() + val isRefreshing = presenter.isRefreshing + + SwipeRefresh( + modifier = Modifier.nestedScroll(nestedScrollInterop), + state = rememberSwipeRefreshState(isRefreshing), + indicator = { s, trigger -> SwipeRefreshIndicator(s, trigger) }, + onRefresh = onRefresh, + ) { + when (state) { + is ExtensionState.Initialized -> { + ExtensionContent( + items = (state as ExtensionState.Initialized).list, + onLongClickItem = onLongClickItem, + onClickItemCancel = onClickItemCancel, + onInstallExtension = onInstallExtension, + onUninstallExtension = onUninstallExtension, + onUpdateExtension = onUpdateExtension, + onTrustExtension = onTrustExtension, + onOpenExtension = onOpenExtension, + onClickUpdateAll = onClickUpdateAll, + onLaunched = onLaunched, + ) + } + ExtensionState.Uninitialized -> {} + } + } +} + +@Composable +fun ExtensionContent( + items: List, + onLongClickItem: (AnimeExtension) -> Unit, + onClickItemCancel: (AnimeExtension) -> Unit, + onInstallExtension: (AnimeExtension.Available) -> Unit, + onUninstallExtension: (AnimeExtension) -> Unit, + onUpdateExtension: (AnimeExtension.Installed) -> Unit, + onTrustExtension: (AnimeExtension.Untrusted) -> Unit, + onOpenExtension: (AnimeExtension.Installed) -> Unit, + onClickUpdateAll: () -> Unit, + onLaunched: () -> Unit, +) { + var trustState by remember { mutableStateOf(null) } + + ScrollbarLazyColumn( + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, + ) { + items( + items = items, + key = { + when (it) { + is ExtensionUiModel.Header.Resource -> it.textRes + is ExtensionUiModel.Header.Text -> it.text + is ExtensionUiModel.Item -> it.key() + } + }, + contentType = { + when (it) { + is ExtensionUiModel.Item -> "item" + else -> "header" + } + }, + ) { item -> + when (item) { + is ExtensionUiModel.Header.Resource -> { + val action: @Composable RowScope.() -> Unit = + if (item.textRes == R.string.ext_updates_pending) { + { + Button(onClick = { onClickUpdateAll() }) { + Text( + text = stringResource(R.string.ext_update_all), + style = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onPrimary, + ), + ) + } + } + } else { + {} + } + ExtensionHeader( + textRes = item.textRes, + modifier = Modifier.animateItemPlacement(), + action = action, + ) + } + is ExtensionUiModel.Header.Text -> { + ExtensionHeader( + text = item.text, + modifier = Modifier.animateItemPlacement(), + ) + } + is ExtensionUiModel.Item -> { + AnimeExtensionItem( + modifier = Modifier.animateItemPlacement(), + item = item, + onClickItem = { + when (it) { + is AnimeExtension.Available -> onInstallExtension(it) + is AnimeExtension.Installed -> { + if (it.hasUpdate) { + onUpdateExtension(it) + } else { + onOpenExtension(it) + } + } + is AnimeExtension.Untrusted -> { trustState = it } + } + }, + onLongClickItem = onLongClickItem, + onClickItemCancel = onClickItemCancel, + onClickItemAction = { + when (it) { + is AnimeExtension.Available -> onInstallExtension(it) + is AnimeExtension.Installed -> { + if (it.hasUpdate) { + onUpdateExtension(it) + } else { + onOpenExtension(it) + } + } + is AnimeExtension.Untrusted -> { trustState = it } + } + }, + ) + LaunchedEffect(Unit) { + onLaunched() + } + } + } + } + } + if (trustState != null) { + ExtensionTrustDialog( + onClickConfirm = { + onTrustExtension(trustState!!) + trustState = null + }, + onClickDismiss = { + onUninstallExtension(trustState!!) + trustState = null + }, + onDismissRequest = { + trustState = null + }, + ) + } +} + +@Composable +fun AnimeExtensionItem( + modifier: Modifier = Modifier, + item: ExtensionUiModel.Item, + onClickItem: (AnimeExtension) -> Unit, + onLongClickItem: (AnimeExtension) -> Unit, + onClickItemCancel: (AnimeExtension) -> Unit, + onClickItemAction: (AnimeExtension) -> Unit, +) { + val (extension, installStep) = item + BaseBrowseItem( + modifier = modifier + .combinedClickable( + onClick = { onClickItem(extension) }, + onLongClick = { onLongClickItem(extension) }, + ), + onClickItem = { onClickItem(extension) }, + onLongClickItem = { onLongClickItem(extension) }, + icon = { + ExtensionIcon(extension = extension) + }, + action = { + ExtensionItemActions( + extension = extension, + installStep = installStep, + onClickItemCancel = onClickItemCancel, + onClickItemAction = onClickItemAction, + ) + }, + ) { + ExtensionItemContent( + extension = extension, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +fun ExtensionItemContent( + extension: AnimeExtension, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val warning = remember(extension) { + when { + extension is AnimeExtension.Untrusted -> R.string.ext_untrusted + extension is AnimeExtension.Installed && extension.isUnofficial -> R.string.ext_unofficial + extension is AnimeExtension.Installed && extension.isObsolete -> R.string.ext_obsolete + extension.isNsfw -> R.string.ext_nsfw_short + else -> null + } + } + + Column( + modifier = modifier.padding(start = horizontalPadding), + ) { + Text( + text = extension.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (extension.lang.isNullOrEmpty().not()) { + Text( + text = LocaleHelper.getSourceDisplayName(extension.lang, context), + style = MaterialTheme.typography.bodySmall, + ) + } + + if (extension.versionName.isNotEmpty()) { + Text( + text = extension.versionName, + style = MaterialTheme.typography.bodySmall, + ) + } + + if (warning != null) { + Text( + text = stringResource(id = warning).uppercase(), + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.error, + ), + ) + } + } + } +} + +@Composable +fun ExtensionItemActions( + extension: AnimeExtension, + installStep: InstallStep, + modifier: Modifier = Modifier, + onClickItemCancel: (AnimeExtension) -> Unit = {}, + onClickItemAction: (AnimeExtension) -> Unit = {}, +) { + val isIdle = remember(installStep) { + installStep == InstallStep.Idle || installStep == InstallStep.Error + } + Row(modifier = modifier) { + TextButton( + onClick = { onClickItemAction(extension) }, + enabled = isIdle, + ) { + Text( + text = when (installStep) { + InstallStep.Pending -> stringResource(R.string.ext_pending) + InstallStep.Downloading -> stringResource(R.string.ext_downloading) + InstallStep.Installing -> stringResource(R.string.ext_installing) + InstallStep.Installed -> stringResource(R.string.ext_installed) + InstallStep.Error -> stringResource(R.string.action_retry) + InstallStep.Idle -> { + when (extension) { + is AnimeExtension.Installed -> { + if (extension.hasUpdate) { + stringResource(R.string.ext_update) + } else { + stringResource(R.string.action_settings) + } + } + is AnimeExtension.Untrusted -> stringResource(R.string.ext_trust) + is AnimeExtension.Available -> stringResource(R.string.ext_install) + } + } + }, + style = LocalTextStyle.current.copy( + color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint, + ), + ) + } + if (isIdle.not()) { + IconButton(onClick = { onClickItemCancel(extension) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "", + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/animesource/AnimeSourceFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceFilterScreen.kt similarity index 91% rename from app/src/main/java/eu/kanade/presentation/animesource/AnimeSourceFilterScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/AnimeSourceFilterScreen.kt index 3faed6de28..30776ab789 100644 --- a/app/src/main/java/eu/kanade/presentation/animesource/AnimeSourceFilterScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceFilterScreen.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.animesource +package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -16,7 +16,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.animesource.components.BaseAnimeSourceItem +import eu.kanade.presentation.browse.components.BaseAnimeSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.PreferenceRow @@ -31,7 +31,7 @@ fun AnimeSourceFilterScreen( nestedScrollInterop: NestedScrollConnection, presenter: AnimeSourceFilterPresenter, onClickLang: (String) -> Unit, - onClickSource: (AnimeSource) -> Unit + onClickSource: (AnimeSource) -> Unit, ) { val state by presenter.state.collectAsState() @@ -53,7 +53,7 @@ fun AnimeSourceFilterContent( nestedScrollInterop: NestedScrollConnection, items: List, onClickLang: (String) -> Unit, - onClickSource: (AnimeSource) -> Unit + onClickSource: (AnimeSource) -> Unit, ) { if (items.isEmpty()) { EmptyScreen(textResource = R.string.source_filter_empty_screen) @@ -76,7 +76,7 @@ fun AnimeSourceFilterContent( is AnimeFilterUiModel.Header -> it.hashCode() is AnimeFilterUiModel.Item -> it.source.key() } - } + }, ) { model -> when (model) { is AnimeFilterUiModel.Header -> { @@ -84,14 +84,14 @@ fun AnimeSourceFilterContent( modifier = Modifier.animateItemPlacement(), language = model.language, isEnabled = model.isEnabled, - onClickItem = onClickLang + onClickItem = onClickLang, ) } is AnimeFilterUiModel.Item -> AnimeSourceFilterItem( modifier = Modifier.animateItemPlacement(), source = model.source, isEnabled = model.isEnabled, - onClickItem = onClickSource + onClickItem = onClickSource, ) } } @@ -103,7 +103,7 @@ fun AnimeSourceFilterHeader( modifier: Modifier, language: String, isEnabled: Boolean, - onClickItem: (String) -> Unit + onClickItem: (String) -> Unit, ) { PreferenceRow( modifier = modifier, @@ -120,7 +120,7 @@ fun AnimeSourceFilterItem( modifier: Modifier, source: AnimeSource, isEnabled: Boolean, - onClickItem: (AnimeSource) -> Unit + onClickItem: (AnimeSource) -> Unit, ) { BaseAnimeSourceItem( modifier = modifier, @@ -129,6 +129,6 @@ fun AnimeSourceFilterItem( onClickItem = { onClickItem(source) }, action = { Checkbox(checked = isEnabled, onCheckedChange = null) - } + }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/animesource/AnimeSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceScreen.kt similarity index 85% rename from app/src/main/java/eu/kanade/presentation/animesource/AnimeSourceScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/AnimeSourceScreen.kt index fdce274bbd..41da68ccbc 100644 --- a/app/src/main/java/eu/kanade/presentation/animesource/AnimeSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeSourceScreen.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.animesource +package eu.kanade.presentation.browse import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -30,27 +30,23 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.model.Pin -import eu.kanade.presentation.animesource.components.BaseAnimeSourceItem +import eu.kanade.presentation.browse.components.BaseAnimeSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.theme.header -import eu.kanade.presentation.util.horizontalPadding import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcePresenter import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourceState -import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesPresenter @Composable -fun AnimeSourceScreen( +fun AnimeSourcesScreen( nestedScrollInterop: NestedScrollConnection, - presenter: AnimeSourcePresenter, + presenter: AnimeSourcesPresenter, onClickItem: (AnimeSource) -> Unit, onClickDisable: (AnimeSource) -> Unit, onClickLatest: (AnimeSource) -> Unit, @@ -105,16 +101,16 @@ fun AnimeSourceList( is AnimeSourceUiModel.Header -> it.hashCode() is AnimeSourceUiModel.Item -> it.source.key() } - } + }, ) { model -> when (model) { is AnimeSourceUiModel.Header -> { SourceHeader( modifier = Modifier.animateItemPlacement(), - language = model.language + language = model.language, ) } - is AnimeSourceUiModel.Item -> SourceItem( + is AnimeSourceUiModel.Item -> AnimeSourceItem( modifier = Modifier.animateItemPlacement(), source = model.source, onClickItem = onClickItem, @@ -139,33 +135,19 @@ fun AnimeSourceList( onClickDisable(sourceState) setSourceState(null) }, - onDismiss = { setSourceState(null) } + onDismiss = { setSourceState(null) }, ) } } @Composable -fun SourceHeader( - modifier: Modifier = Modifier, - language: String -) { - val context = LocalContext.current - Text( - text = LocaleHelper.getSourceDisplayName(language, context), - modifier = modifier - .padding(horizontal = horizontalPadding, vertical = 8.dp), - style = MaterialTheme.typography.header - ) -} - -@Composable -fun SourceItem( +fun AnimeSourceItem( modifier: Modifier = Modifier, source: AnimeSource, onClickItem: (AnimeSource) -> Unit, onLongClickItem: (AnimeSource) -> Unit, onClickLatest: (AnimeSource) -> Unit, - onClickPin: (AnimeSource) -> Unit + onClickPin: (AnimeSource) -> Unit, ) { BaseAnimeSourceItem( modifier = modifier, @@ -178,14 +160,14 @@ fun SourceItem( Text( text = stringResource(id = R.string.latest), style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.primary - ) + color = MaterialTheme.colorScheme.primary, + ), ) } } AnimeSourcePinButton( isPinned = Pin.Pinned in source.pin, - onClick = { onClickPin(source) } + onClick = { onClickPin(source) }, ) }, ) @@ -193,7 +175,7 @@ fun SourceItem( @Composable fun AnimeSourceIcon( - source: AnimeSource + source: AnimeSource, ) { val icon = source.icon val modifier = Modifier @@ -217,7 +199,7 @@ fun AnimeSourceIcon( @Composable fun AnimeSourcePinButton( isPinned: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground @@ -225,7 +207,7 @@ fun AnimeSourcePinButton( Icon( imageVector = icon, contentDescription = "", - tint = tint + tint = tint, ) } } @@ -249,7 +231,7 @@ fun AnimeSourceOptionsDialog( modifier = Modifier .clickable(onClick = onClickPin) .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 16.dp), ) if (source.id != LocalSource.ID) { Text( @@ -257,7 +239,7 @@ fun AnimeSourceOptionsDialog( modifier = Modifier .clickable(onClick = onClickDisable) .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 16.dp), ) } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt new file mode 100644 index 0000000000..63ae5d28e3 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -0,0 +1,349 @@ +package eu.kanade.presentation.browse + +import android.util.DisplayMetrics +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +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.runtime.setValue +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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.DIVIDER_ALPHA +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsPresenter +import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun ExtensionDetailsScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: ExtensionDetailsPresenter, + onClickUninstall: () -> Unit, + onClickAppInfo: () -> Unit, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickSource: (sourceId: Long) -> Unit, +) { + val extension = presenter.extension + + if (extension == null) { + EmptyScreen(textResource = R.string.empty_screen) + return + } + + val sources by presenter.sourcesState.collectAsState() + + var showNsfwWarning by remember { mutableStateOf(false) } + + ScrollbarLazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + when { + extension.isUnofficial -> + item { + WarningBanner(R.string.unofficial_extension_message_tachiyomi) + } + extension.isObsolete -> + item { + WarningBanner(R.string.obsolete_extension_message) + } + } + + item { + DetailsHeader( + extension = extension, + onClickUninstall = onClickUninstall, + onClickAppInfo = onClickAppInfo, + onClickAgeRating = { + showNsfwWarning = true + }, + ) + } + + items( + items = sources, + key = { it.source.id }, + ) { source -> + SourceSwitchPreference( + modifier = Modifier.animateItemPlacement(), + source = source, + onClickSourcePreferences = onClickSourcePreferences, + onClickSource = onClickSource, + ) + } + } + if (showNsfwWarning) { + NsfwWarningDialog( + onClickConfirm = { + showNsfwWarning = false + }, + ) + } +} + +@Composable +private fun WarningBanner(@StringRes textRes: Int) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.error) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(textRes), + color = MaterialTheme.colorScheme.onError, + ) + } +} + +@Composable +private fun DetailsHeader( + extension: Extension, + onClickAgeRating: () -> Unit, + onClickUninstall: () -> Unit, + onClickAppInfo: () -> Unit, +) { + val context = LocalContext.current + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = 16.dp, + bottom = 8.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ExtensionIcon( + modifier = Modifier + .size(112.dp), + extension = extension, + density = DisplayMetrics.DENSITY_XXXHIGH, + ) + + Text( + text = extension.name, + style = MaterialTheme.typography.headlineSmall, + ) + + val strippedPkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") + + Text( + text = strippedPkgName, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = horizontalPadding * 2, + vertical = 8.dp, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + InfoText( + primaryText = extension.versionName, + secondaryText = stringResource(R.string.ext_info_version), + ) + + InfoDivider() + + InfoText( + primaryText = LocaleHelper.getSourceDisplayName(extension.lang, context), + secondaryText = stringResource(R.string.ext_info_language), + ) + + if (extension.isNsfw) { + InfoDivider() + + InfoText( + primaryText = stringResource(R.string.ext_nsfw_short), + primaryTextStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium, + ), + secondaryText = stringResource(R.string.ext_info_age_rating), + onClick = onClickAgeRating, + ) + } + } + + Row( + modifier = Modifier.padding( + start = horizontalPadding, + end = horizontalPadding, + top = 8.dp, + bottom = 16.dp, + ), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onClickUninstall, + ) { + Text(stringResource(R.string.ext_uninstall)) + } + + Spacer(Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onClickAppInfo, + ) { + Text( + text = stringResource(R.string.ext_app_info), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + Divider() + } +} + +@Composable +private fun InfoText( + primaryText: String, + primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge, + secondaryText: String, + onClick: (() -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + + val modifier = if (onClick != null) { + Modifier.clickable(interactionSource, indication = null) { onClick() } + } else Modifier + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = primaryText, + style = primaryTextStyle, + ) + + Text( + text = secondaryText + if (onClick != null) " ⓘ" else "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } +} + +@Composable +private fun InfoDivider() { + Divider( + modifier = Modifier + .height(20.dp) + .width(1.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA), + ) +} + +@Composable +private fun SourceSwitchPreference( + modifier: Modifier = Modifier, + source: ExtensionSourceItem, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickSource: (sourceId: Long) -> Unit, +) { + val context = LocalContext.current + + PreferenceRow( + modifier = modifier, + title = if (source.labelAsName) { + source.source.toString() + } else { + LocaleHelper.getSourceDisplayName(source.source.lang, context) + }, + onClick = { onClickSource(source.source.id) }, + action = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (source.source is ConfigurableSource) { + IconButton(onClick = { onClickSourcePreferences(source.source.id) }) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.label_settings), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Switch(checked = source.enabled, onCheckedChange = null) + } + }, + ) +} + +@Composable +fun NsfwWarningDialog( + onClickConfirm: () -> Unit, +) { + AlertDialog( + text = { + Text(text = stringResource(R.string.ext_nsfw_warning)) + }, + confirmButton = { + TextButton(onClick = onClickConfirm) { + Text(text = stringResource(android.R.string.ok)) + } + }, + onDismissRequest = onClickConfirm, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionLangFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionLangFilterScreen.kt new file mode 100644 index 0000000000..c24484b675 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionLangFilterScreen.kt @@ -0,0 +1,89 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionFilterPresenter +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionFilterState +import eu.kanade.tachiyomi.ui.browse.extension.FilterUiModel +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun ExtensionFilterScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: ExtensionFilterPresenter, + onClickLang: (String) -> Unit, +) { + val state by presenter.state.collectAsState() + + when (state) { + is ExtensionFilterState.Loading -> LoadingScreen() + is ExtensionFilterState.Error -> Text(text = (state as ExtensionFilterState.Error).error.message!!) + is ExtensionFilterState.Success -> + SourceFilterContent( + nestedScrollInterop = nestedScrollInterop, + items = (state as ExtensionFilterState.Success).models, + onClickLang = onClickLang, + ) + } +} + +@Composable +fun SourceFilterContent( + nestedScrollInterop: NestedScrollConnection, + items: List, + onClickLang: (String) -> Unit, +) { + if (items.isEmpty()) { + EmptyScreen(textResource = R.string.empty_screen) + return + } + + LazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + items( + items = items, + ) { model -> + ExtensionFilterItem( + modifier = Modifier.animateItemPlacement(), + lang = model.lang, + enabled = model.enabled, + onClickItem = onClickLang, + ) + } + } +} + +@Composable +fun ExtensionFilterItem( + modifier: Modifier, + lang: String, + enabled: Boolean, + onClickItem: (String) -> Unit, +) { + PreferenceRow( + modifier = modifier, + title = LocaleHelper.getSourceDisplayName(lang, LocalContext.current), + action = { + Switch(checked = enabled, onCheckedChange = null) + }, + onClick = { onClickItem(lang) }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt new file mode 100644 index 0000000000..0c3a7cd419 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -0,0 +1,425 @@ +package eu.kanade.presentation.browse + +import androidx.annotation.StringRes +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import eu.kanade.presentation.browse.components.BaseBrowseItem +import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.components.SwipeRefreshIndicator +import eu.kanade.presentation.theme.header +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionState +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun ExtensionScreen( + nestedScrollInterop: NestedScrollConnection, + presenter: ExtensionsPresenter, + onLongClickItem: (Extension) -> Unit, + onClickItemCancel: (Extension) -> Unit, + onInstallExtension: (Extension.Available) -> Unit, + onUninstallExtension: (Extension) -> Unit, + onUpdateExtension: (Extension.Installed) -> Unit, + onTrustExtension: (Extension.Untrusted) -> Unit, + onOpenExtension: (Extension.Installed) -> Unit, + onClickUpdateAll: () -> Unit, + onRefresh: () -> Unit, + onLaunched: () -> Unit, +) { + val state by presenter.state.collectAsState() + val isRefreshing = presenter.isRefreshing + + SwipeRefresh( + modifier = Modifier.nestedScroll(nestedScrollInterop), + state = rememberSwipeRefreshState(isRefreshing), + indicator = { s, trigger -> SwipeRefreshIndicator(s, trigger) }, + onRefresh = onRefresh, + ) { + when (state) { + is ExtensionState.Initialized -> { + ExtensionContent( + items = (state as ExtensionState.Initialized).list, + onLongClickItem = onLongClickItem, + onClickItemCancel = onClickItemCancel, + onInstallExtension = onInstallExtension, + onUninstallExtension = onUninstallExtension, + onUpdateExtension = onUpdateExtension, + onTrustExtension = onTrustExtension, + onOpenExtension = onOpenExtension, + onClickUpdateAll = onClickUpdateAll, + onLaunched = onLaunched, + ) + } + ExtensionState.Uninitialized -> {} + } + } +} + +@Composable +fun ExtensionContent( + items: List, + onLongClickItem: (Extension) -> Unit, + onClickItemCancel: (Extension) -> Unit, + onInstallExtension: (Extension.Available) -> Unit, + onUninstallExtension: (Extension) -> Unit, + onUpdateExtension: (Extension.Installed) -> Unit, + onTrustExtension: (Extension.Untrusted) -> Unit, + onOpenExtension: (Extension.Installed) -> Unit, + onClickUpdateAll: () -> Unit, + onLaunched: () -> Unit, +) { + var trustState by remember { mutableStateOf(null) } + + ScrollbarLazyColumn( + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, + ) { + items( + items = items, + key = { + when (it) { + is ExtensionUiModel.Header.Resource -> it.textRes + is ExtensionUiModel.Header.Text -> it.text + is ExtensionUiModel.Item -> it.key() + } + }, + contentType = { + when (it) { + is ExtensionUiModel.Item -> "item" + else -> "header" + } + }, + ) { item -> + when (item) { + is ExtensionUiModel.Header.Resource -> { + val action: @Composable RowScope.() -> Unit = + if (item.textRes == R.string.ext_updates_pending) { + { + Button(onClick = { onClickUpdateAll() }) { + Text( + text = stringResource(R.string.ext_update_all), + style = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onPrimary, + ), + ) + } + } + } else { + {} + } + ExtensionHeader( + textRes = item.textRes, + modifier = Modifier.animateItemPlacement(), + action = action, + ) + } + is ExtensionUiModel.Header.Text -> { + ExtensionHeader( + text = item.text, + modifier = Modifier.animateItemPlacement(), + ) + } + is ExtensionUiModel.Item -> { + ExtensionItem( + modifier = Modifier.animateItemPlacement(), + item = item, + onClickItem = { + when (it) { + is Extension.Available -> onInstallExtension(it) + is Extension.Installed -> { + if (it.hasUpdate) { + onUpdateExtension(it) + } else { + onOpenExtension(it) + } + } + is Extension.Untrusted -> { trustState = it } + } + }, + onLongClickItem = onLongClickItem, + onClickItemCancel = onClickItemCancel, + onClickItemAction = { + when (it) { + is Extension.Available -> onInstallExtension(it) + is Extension.Installed -> { + if (it.hasUpdate) { + onUpdateExtension(it) + } else { + onOpenExtension(it) + } + } + is Extension.Untrusted -> { trustState = it } + } + }, + ) + LaunchedEffect(Unit) { + onLaunched() + } + } + } + } + } + if (trustState != null) { + ExtensionTrustDialog( + onClickConfirm = { + onTrustExtension(trustState!!) + trustState = null + }, + onClickDismiss = { + onUninstallExtension(trustState!!) + trustState = null + }, + onDismissRequest = { + trustState = null + }, + ) + } +} + +@Composable +fun ExtensionItem( + modifier: Modifier = Modifier, + item: ExtensionUiModel.Item, + onClickItem: (Extension) -> Unit, + onLongClickItem: (Extension) -> Unit, + onClickItemCancel: (Extension) -> Unit, + onClickItemAction: (Extension) -> Unit, +) { + val (extension, installStep) = item + BaseBrowseItem( + modifier = modifier + .combinedClickable( + onClick = { onClickItem(extension) }, + onLongClick = { onLongClickItem(extension) }, + ), + onClickItem = { onClickItem(extension) }, + onLongClickItem = { onLongClickItem(extension) }, + icon = { + ExtensionIcon(extension = extension) + }, + action = { + ExtensionItemActions( + extension = extension, + installStep = installStep, + onClickItemCancel = onClickItemCancel, + onClickItemAction = onClickItemAction, + ) + }, + ) { + ExtensionItemContent( + extension = extension, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +fun ExtensionItemContent( + extension: Extension, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val warning = remember(extension) { + when { + extension is Extension.Untrusted -> R.string.ext_untrusted + extension is Extension.Installed && extension.isUnofficial -> R.string.ext_unofficial + extension is Extension.Installed && extension.isObsolete -> R.string.ext_obsolete + extension.isNsfw -> R.string.ext_nsfw_short + else -> null + } + } + + Column( + modifier = modifier.padding(start = horizontalPadding), + ) { + Text( + text = extension.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (extension.lang.isNullOrEmpty().not()) { + Text( + text = LocaleHelper.getSourceDisplayName(extension.lang, context), + style = MaterialTheme.typography.bodySmall, + ) + } + + if (extension.versionName.isNotEmpty()) { + Text( + text = extension.versionName, + style = MaterialTheme.typography.bodySmall, + ) + } + + if (warning != null) { + Text( + text = stringResource(id = warning).uppercase(), + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.error, + ), + ) + } + } + } +} + +@Composable +fun ExtensionItemActions( + extension: Extension, + installStep: InstallStep, + modifier: Modifier = Modifier, + onClickItemCancel: (Extension) -> Unit = {}, + onClickItemAction: (Extension) -> Unit = {}, +) { + val isIdle = remember(installStep) { + installStep == InstallStep.Idle || installStep == InstallStep.Error + } + Row(modifier = modifier) { + TextButton( + onClick = { onClickItemAction(extension) }, + enabled = isIdle, + ) { + Text( + text = when (installStep) { + InstallStep.Pending -> stringResource(R.string.ext_pending) + InstallStep.Downloading -> stringResource(R.string.ext_downloading) + InstallStep.Installing -> stringResource(R.string.ext_installing) + InstallStep.Installed -> stringResource(R.string.ext_installed) + InstallStep.Error -> stringResource(R.string.action_retry) + InstallStep.Idle -> { + when (extension) { + is Extension.Installed -> { + if (extension.hasUpdate) { + stringResource(R.string.ext_update) + } else { + stringResource(R.string.action_settings) + } + } + is Extension.Untrusted -> stringResource(R.string.ext_trust) + is Extension.Available -> stringResource(R.string.ext_install) + } + } + }, + style = LocalTextStyle.current.copy( + color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint, + ), + ) + } + if (isIdle.not()) { + IconButton(onClick = { onClickItemCancel(extension) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "", + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} + +@Composable +fun ExtensionHeader( + @StringRes textRes: Int, + modifier: Modifier = Modifier, + action: @Composable RowScope.() -> Unit = {}, +) { + ExtensionHeader( + text = stringResource(id = textRes), + modifier = modifier, + action = action, + ) +} + +@Composable +fun ExtensionHeader( + text: String, + modifier: Modifier = Modifier, + action: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier.padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f), + style = MaterialTheme.typography.header, + ) + action() + } +} + +@Composable +fun ExtensionTrustDialog( + onClickConfirm: () -> Unit, + onClickDismiss: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + title = { + Text(text = stringResource(R.string.untrusted_extension)) + }, + text = { + Text(text = stringResource(R.string.untrusted_extension_message)) + }, + confirmButton = { + TextButton(onClick = onClickConfirm) { + Text(text = stringResource(R.string.ext_trust)) + } + }, + dismissButton = { + TextButton(onClick = onClickDismiss) { + Text(text = stringResource(R.string.ext_uninstall)) + } + }, + onDismissRequest = onDismissRequest, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/animesource/MigrateAnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeScreen.kt similarity index 86% rename from app/src/main/java/eu/kanade/presentation/animesource/MigrateAnimeScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeScreen.kt index 154b3d47c5..6cad947bd7 100644 --- a/app/src/main/java/eu/kanade/presentation/animesource/MigrateAnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeScreen.kt @@ -1,9 +1,8 @@ -package eu.kanade.presentation.animesource +package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,6 +15,7 @@ import eu.kanade.domain.anime.model.Anime import eu.kanade.presentation.anime.components.BaseAnimeListItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.migration.anime.MigrateAnimeState import eu.kanade.tachiyomi.ui.browse.migration.anime.MigrationAnimePresenter @@ -25,7 +25,7 @@ fun MigrateAnimeScreen( nestedScrollInterop: NestedScrollConnection, presenter: MigrationAnimePresenter, onClickItem: (Anime) -> Unit, - onClickCover: (Anime) -> Unit + onClickCover: (Anime) -> Unit, ) { val state by presenter.state.collectAsState() @@ -48,13 +48,13 @@ fun MigrateAnimeContent( nestedScrollInterop: NestedScrollConnection, list: List, onClickItem: (Anime) -> Unit, - onClickCover: (Anime) -> Unit + onClickCover: (Anime) -> Unit, ) { if (list.isEmpty()) { - EmptyScreen(textResource = R.string.migrate_empty_screen) + EmptyScreen(textResource = R.string.empty_screen) return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { @@ -62,7 +62,7 @@ fun MigrateAnimeContent( MigrateAnimeItem( anime = anime, onClickItem = onClickItem, - onClickCover = onClickCover + onClickCover = onClickCover, ) } } @@ -73,12 +73,12 @@ fun MigrateAnimeItem( modifier: Modifier = Modifier, anime: Anime, onClickItem: (Anime) -> Unit, - onClickCover: (Anime) -> Unit + onClickCover: (Anime) -> Unit, ) { BaseAnimeListItem( modifier = modifier, anime = anime, onClickItem = { onClickItem(anime) }, - onClickCover = { onClickCover(anime) } + onClickCover = { onClickCover(anime) }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/animesource/MigrateAnimeSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeSourceScreen.kt similarity index 87% rename from app/src/main/java/eu/kanade/presentation/animesource/MigrateAnimeSourceScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeSourceScreen.kt index b89d3bab57..0a0dc3955f 100644 --- a/app/src/main/java/eu/kanade/presentation/animesource/MigrateAnimeSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateAnimeSourceScreen.kt @@ -1,10 +1,9 @@ -package eu.kanade.presentation.animesource +package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -17,12 +16,15 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.animesource.components.BaseAnimeSourceItem +import eu.kanade.presentation.browse.components.BaseAnimeSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.ItemBadges import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.theme.header import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.migration.animesources.MigrateAnimeSourceState import eu.kanade.tachiyomi.ui.browse.migration.animesources.MigrationAnimeSourcesPresenter @@ -60,17 +62,17 @@ fun MigrateAnimeSourceList( return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, ) { item(key = "title") { Text( - text = stringResource(id = R.string.migration_selection_prompt), + text = stringResource(R.string.migration_selection_prompt), modifier = Modifier .animateItemPlacement() .padding(horizontal = horizontalPadding, vertical = 8.dp), - style = MaterialTheme.typography.header + style = MaterialTheme.typography.header, ) } @@ -78,14 +80,14 @@ fun MigrateAnimeSourceList( items = list, key = { (source, _) -> source.id - } + }, ) { (source, count) -> MigrateAnimeSourceItem( modifier = Modifier.animateItemPlacement(), source = source, count = count, onClickItem = { onClickItem(source) }, - onLongClickItem = { onLongClickItem(source) } + onLongClickItem = { onLongClickItem(source) }, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/source/MigrateMangaScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt similarity index 86% rename from app/src/main/java/eu/kanade/presentation/source/MigrateMangaScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt index 2bb0526387..6dd5e42984 100644 --- a/app/src/main/java/eu/kanade/presentation/source/MigrateMangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt @@ -1,9 +1,8 @@ -package eu.kanade.presentation.source +package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +14,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import eu.kanade.domain.manga.model.Manga import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.manga.components.BaseMangaListItem import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaState @@ -25,7 +25,7 @@ fun MigrateMangaScreen( nestedScrollInterop: NestedScrollConnection, presenter: MigrationMangaPresenter, onClickItem: (Manga) -> Unit, - onClickCover: (Manga) -> Unit + onClickCover: (Manga) -> Unit, ) { val state by presenter.state.collectAsState() @@ -48,13 +48,13 @@ fun MigrateMangaContent( nestedScrollInterop: NestedScrollConnection, list: List, onClickItem: (Manga) -> Unit, - onClickCover: (Manga) -> Unit + onClickCover: (Manga) -> Unit, ) { if (list.isEmpty()) { - EmptyScreen(textResource = R.string.migrate_empty_screen) + EmptyScreen(textResource = R.string.empty_screen) return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { @@ -62,7 +62,7 @@ fun MigrateMangaContent( MigrateMangaItem( manga = manga, onClickItem = onClickItem, - onClickCover = onClickCover + onClickCover = onClickCover, ) } } @@ -73,12 +73,12 @@ fun MigrateMangaItem( modifier: Modifier = Modifier, manga: Manga, onClickItem: (Manga) -> Unit, - onClickCover: (Manga) -> Unit + onClickCover: (Manga) -> Unit, ) { BaseMangaListItem( modifier = modifier, manga = manga, onClickItem = { onClickItem(manga) }, - onClickCover = { onClickCover(manga) } + onClickCover = { onClickCover(manga) }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/source/MigrateSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt similarity index 87% rename from app/src/main/java/eu/kanade/presentation/source/MigrateSourceScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt index 37acf32f06..20a6c5826e 100644 --- a/app/src/main/java/eu/kanade/presentation/source/MigrateSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt @@ -1,10 +1,9 @@ -package eu.kanade.presentation.source +package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -17,12 +16,15 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.kanade.domain.source.model.Source +import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.ItemBadges import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.source.components.BaseSourceItem +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.theme.header import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceState import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter @@ -60,17 +62,17 @@ fun MigrateSourceList( return } - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, ) { item(key = "title") { Text( - text = stringResource(id = R.string.migration_selection_prompt), + text = stringResource(R.string.migration_selection_prompt), modifier = Modifier .animateItemPlacement() .padding(horizontal = horizontalPadding, vertical = 8.dp), - style = MaterialTheme.typography.header + style = MaterialTheme.typography.header, ) } @@ -78,14 +80,14 @@ fun MigrateSourceList( items = list, key = { (source, _) -> source.id - } + }, ) { (source, count) -> MigrateSourceItem( modifier = Modifier.animateItemPlacement(), source = source, count = count, onClickItem = { onClickItem(source) }, - onLongClickItem = { onLongClickItem(source) } + onLongClickItem = { onLongClickItem(source) }, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/source/SourceFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt similarity index 75% rename from app/src/main/java/eu/kanade/presentation/source/SourceFilterScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt index 8e577af077..097a76264c 100644 --- a/app/src/main/java/eu/kanade/presentation/source/SourceFilterScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt @@ -1,9 +1,8 @@ -package eu.kanade.presentation.source +package eu.kanade.presentation.browse import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Checkbox import androidx.compose.material3.Switch @@ -16,30 +15,31 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import eu.kanade.domain.source.model.Source +import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.PreferenceRow -import eu.kanade.presentation.source.components.BaseSourceItem +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.browse.source.FilterUiModel -import eu.kanade.tachiyomi.ui.browse.source.SourceFilterPresenter import eu.kanade.tachiyomi.ui.browse.source.SourceFilterState +import eu.kanade.tachiyomi.ui.browse.source.SourcesFilterPresenter import eu.kanade.tachiyomi.util.system.LocaleHelper @Composable -fun SourceFilterScreen( +fun SourcesFilterScreen( nestedScrollInterop: NestedScrollConnection, - presenter: SourceFilterPresenter, + presenter: SourcesFilterPresenter, onClickLang: (String) -> Unit, - onClickSource: (Source) -> Unit + onClickSource: (Source) -> Unit, ) { val state by presenter.state.collectAsState() when (state) { is SourceFilterState.Loading -> LoadingScreen() - is SourceFilterState.Error -> Text(text = (state as SourceFilterState.Error).error!!.message!!) + is SourceFilterState.Error -> Text(text = (state as SourceFilterState.Error).error.message!!) is SourceFilterState.Success -> - SourceFilterContent( + SourcesFilterContent( nestedScrollInterop = nestedScrollInterop, items = (state as SourceFilterState.Success).models, onClickLang = onClickLang, @@ -49,17 +49,18 @@ fun SourceFilterScreen( } @Composable -fun SourceFilterContent( +fun SourcesFilterContent( nestedScrollInterop: NestedScrollConnection, items: List, onClickLang: (String) -> Unit, - onClickSource: (Source) -> Unit + onClickSource: (Source) -> Unit, ) { if (items.isEmpty()) { EmptyScreen(textResource = R.string.source_filter_empty_screen) return } - LazyColumn( + + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { @@ -76,22 +77,22 @@ fun SourceFilterContent( is FilterUiModel.Header -> it.hashCode() is FilterUiModel.Item -> it.source.key() } - } + }, ) { model -> when (model) { is FilterUiModel.Header -> { - SourceFilterHeader( + SourcesFilterHeader( modifier = Modifier.animateItemPlacement(), language = model.language, - isEnabled = model.isEnabled, - onClickItem = onClickLang + enabled = model.enabled, + onClickItem = onClickLang, ) } - is FilterUiModel.Item -> SourceFilterItem( + is FilterUiModel.Item -> SourcesFilterItem( modifier = Modifier.animateItemPlacement(), source = model.source, - isEnabled = model.isEnabled, - onClickItem = onClickSource + enabled = model.enabled, + onClickItem = onClickSource, ) } } @@ -99,28 +100,28 @@ fun SourceFilterContent( } @Composable -fun SourceFilterHeader( +fun SourcesFilterHeader( modifier: Modifier, language: String, - isEnabled: Boolean, - onClickItem: (String) -> Unit + enabled: Boolean, + onClickItem: (String) -> Unit, ) { PreferenceRow( modifier = modifier, title = LocaleHelper.getSourceDisplayName(language, LocalContext.current), action = { - Switch(checked = isEnabled, onCheckedChange = null) + Switch(checked = enabled, onCheckedChange = null) }, onClick = { onClickItem(language) }, ) } @Composable -fun SourceFilterItem( +fun SourcesFilterItem( modifier: Modifier, source: Source, - isEnabled: Boolean, - onClickItem: (Source) -> Unit + enabled: Boolean, + onClickItem: (Source) -> Unit, ) { BaseSourceItem( modifier = modifier, @@ -128,7 +129,7 @@ fun SourceFilterItem( showLanguageInContent = false, onClickItem = { onClickItem(source) }, action = { - Checkbox(checked = isEnabled, onCheckedChange = null) - } + Checkbox(checked = enabled, onCheckedChange = null) + }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt similarity index 77% rename from app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt rename to app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt index b2487936b9..a7230a0907 100644 --- a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt @@ -1,16 +1,12 @@ -package eu.kanade.presentation.source +package eu.kanade.presentation.browse -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PushPin @@ -27,30 +23,33 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.kanade.domain.source.model.Pin import eu.kanade.domain.source.model.Source +import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.source.components.BaseSourceItem +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.theme.header import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.ui.browse.source.SourcePresenter import eu.kanade.tachiyomi.ui.browse.source.SourceState +import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter import eu.kanade.tachiyomi.util.system.LocaleHelper @Composable -fun SourceScreen( +fun SourcesScreen( nestedScrollInterop: NestedScrollConnection, - presenter: SourcePresenter, + presenter: SourcesPresenter, onClickItem: (Source) -> Unit, onClickDisable: (Source) -> Unit, onClickLatest: (Source) -> Unit, @@ -86,11 +85,11 @@ fun SourceList( return } - val (sourceState, setSourceState) = remember { mutableStateOf(null) } - LazyColumn( - modifier = Modifier - .nestedScroll(nestedScrollConnection), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + var sourceState by remember { mutableStateOf(null) } + + ScrollbarLazyColumn( + modifier = Modifier.nestedScroll(nestedScrollConnection), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, ) { items( items = list, @@ -105,22 +104,20 @@ fun SourceList( is SourceUiModel.Header -> it.hashCode() is SourceUiModel.Item -> it.source.key() } - } + }, ) { model -> when (model) { is SourceUiModel.Header -> { SourceHeader( modifier = Modifier.animateItemPlacement(), - language = model.language + language = model.language, ) } is SourceUiModel.Item -> SourceItem( modifier = Modifier.animateItemPlacement(), source = model.source, onClickItem = onClickItem, - onLongClickItem = { - setSourceState(it) - }, + onLongClickItem = { sourceState = it }, onClickLatest = onClickLatest, onClickPin = onClickPin, ) @@ -130,16 +127,16 @@ fun SourceList( if (sourceState != null) { SourceOptionsDialog( - source = sourceState, + source = sourceState!!, onClickPin = { - onClickPin(sourceState) - setSourceState(null) + onClickPin(sourceState!!) + sourceState = null }, onClickDisable = { - onClickDisable(sourceState) - setSourceState(null) + onClickDisable(sourceState!!) + sourceState = null }, - onDismiss = { setSourceState(null) } + onDismiss = { sourceState = null }, ) } } @@ -147,14 +144,14 @@ fun SourceList( @Composable fun SourceHeader( modifier: Modifier = Modifier, - language: String + language: String, ) { val context = LocalContext.current Text( text = LocaleHelper.getSourceDisplayName(language, context), modifier = modifier .padding(horizontal = horizontalPadding, vertical = 8.dp), - style = MaterialTheme.typography.header + style = MaterialTheme.typography.header, ) } @@ -165,7 +162,7 @@ fun SourceItem( onClickItem: (Source) -> Unit, onLongClickItem: (Source) -> Unit, onClickLatest: (Source) -> Unit, - onClickPin: (Source) -> Unit + onClickPin: (Source) -> Unit, ) { BaseSourceItem( modifier = modifier, @@ -176,48 +173,25 @@ fun SourceItem( if (source.supportsLatest) { TextButton(onClick = { onClickLatest(source) }) { Text( - text = stringResource(id = R.string.latest), + text = stringResource(R.string.latest), style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.primary - ) + color = MaterialTheme.colorScheme.primary, + ), ) } } SourcePinButton( isPinned = Pin.Pinned in source.pin, - onClick = { onClickPin(source) } + onClick = { onClickPin(source) }, ) }, ) } -@Composable -fun SourceIcon( - source: Source -) { - val icon = source.icon - val modifier = Modifier - .height(40.dp) - .aspectRatio(1f) - if (icon != null) { - Image( - bitmap = icon, - contentDescription = "", - modifier = modifier, - ) - } else { - Image( - painter = painterResource(id = R.mipmap.ic_local_source), - contentDescription = "", - modifier = modifier, - ) - } -} - @Composable fun SourcePinButton( isPinned: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground @@ -225,7 +199,7 @@ fun SourcePinButton( Icon( imageVector = icon, contentDescription = "", - tint = tint + tint = tint, ) } } @@ -249,15 +223,15 @@ fun SourceOptionsDialog( modifier = Modifier .clickable(onClick = onClickPin) .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 16.dp), ) if (source.id != LocalSource.ID) { Text( - text = stringResource(id = R.string.action_disable), + text = stringResource(R.string.action_disable), modifier = Modifier .clickable(onClick = onClickDisable) .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 16.dp), ) } } diff --git a/app/src/main/java/eu/kanade/presentation/animesource/components/BaseAnimeSourceItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseAnimeSourceItem.kt similarity index 65% rename from app/src/main/java/eu/kanade/presentation/animesource/components/BaseAnimeSourceItem.kt rename to app/src/main/java/eu/kanade/presentation/browse/components/BaseAnimeSourceItem.kt index ab45d2e6f8..ddf1be7847 100644 --- a/app/src/main/java/eu/kanade/presentation/animesource/components/BaseAnimeSourceItem.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BaseAnimeSourceItem.kt @@ -1,19 +1,14 @@ -package eu.kanade.presentation.animesource.components +package eu.kanade.presentation.browse.components -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.animesource.AnimeSourceIcon import eu.kanade.presentation.util.horizontalPadding import eu.kanade.tachiyomi.util.system.LocaleHelper @@ -28,19 +23,14 @@ fun BaseAnimeSourceItem( action: @Composable RowScope.(AnimeSource) -> Unit = {}, content: @Composable RowScope.(AnimeSource, Boolean) -> Unit = defaultContent, ) { - Row( - modifier = modifier - .combinedClickable( - onClick = onClickItem, - onLongClick = onLongClickItem - ) - .padding(horizontal = horizontalPadding, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - icon.invoke(this, source) - content.invoke(this, source, showLanguageInContent) - action.invoke(this, source) - } + BaseBrowseItem( + modifier = modifier, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + icon = { icon.invoke(this, source) }, + action = { action.invoke(this, source) }, + content = { content.invoke(this, source, showLanguageInContent) }, + ) } private val defaultIcon: @Composable RowScope.(AnimeSource) -> Unit = { source -> @@ -51,20 +41,20 @@ private val defaultContent: @Composable RowScope.(AnimeSource, Boolean) -> Unit Column( modifier = Modifier .padding(horizontal = horizontalPadding) - .weight(1f) + .weight(1f), ) { Text( text = source.name, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) if (showLanguageInContent) { Text( text = LocaleHelper.getDisplayName(source.lang), maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt new file mode 100644 index 0000000000..2b3dd022fb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt @@ -0,0 +1,35 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.horizontalPadding + +@Composable +fun BaseBrowseItem( + modifier: Modifier = Modifier, + onClickItem: () -> Unit = {}, + onLongClickItem: () -> Unit = {}, + icon: @Composable RowScope.() -> Unit = {}, + action: @Composable RowScope.() -> Unit = {}, + content: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier + .combinedClickable( + onClick = onClickItem, + onLongClick = onLongClickItem, + ) + .padding(horizontal = horizontalPadding, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + content() + action() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt similarity index 65% rename from app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt rename to app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt index 0e49eced84..fed26e8363 100644 --- a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt @@ -1,19 +1,14 @@ -package eu.kanade.presentation.source.components +package eu.kanade.presentation.browse.components -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import eu.kanade.domain.source.model.Source -import eu.kanade.presentation.source.SourceIcon import eu.kanade.presentation.util.horizontalPadding import eu.kanade.tachiyomi.util.system.LocaleHelper @@ -28,19 +23,14 @@ fun BaseSourceItem( action: @Composable RowScope.(Source) -> Unit = {}, content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent, ) { - Row( - modifier = modifier - .combinedClickable( - onClick = onClickItem, - onLongClick = onLongClickItem - ) - .padding(horizontal = horizontalPadding, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - icon.invoke(this, source) - content.invoke(this, source, showLanguageInContent) - action.invoke(this, source) - } + BaseBrowseItem( + modifier = modifier, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + icon = { icon.invoke(this, source) }, + action = { action.invoke(this, source) }, + content = { content.invoke(this, source, showLanguageInContent) }, + ) } private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source -> @@ -51,20 +41,20 @@ private val defaultContent: @Composable RowScope.(Source, Boolean) -> Unit = { s Column( modifier = Modifier .padding(horizontal = horizontalPadding) - .weight(1f) + .weight(1f), ) { Text( text = source.name, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) if (showLanguageInContent) { Text( text = LocaleHelper.getDisplayName(source.lang), maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseAnimeIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseAnimeIcons.kt new file mode 100644 index 0000000000..222b45eb6d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseAnimeIcons.kt @@ -0,0 +1,118 @@ +package eu.kanade.presentation.browse.components + +import android.content.pm.PackageManager +import android.util.DisplayMetrics +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import coil.compose.AsyncImage +import eu.kanade.domain.animesource.model.AnimeSource +import eu.kanade.presentation.util.bitmapPainterResource +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.util.lang.withIOContext + +private val defaultModifier = Modifier + .height(40.dp) + .aspectRatio(1f) + +@Composable +fun AnimeSourceIcon( + source: AnimeSource, + modifier: Modifier = Modifier, +) { + val icon = source.icon + + if (icon != null) { + Image( + bitmap = icon, + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } else { + Image( + painter = painterResource(id = R.mipmap.ic_local_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } +} + +@Composable +fun ExtensionIcon( + extension: AnimeExtension, + modifier: Modifier = Modifier, + density: Int = DisplayMetrics.DENSITY_DEFAULT, +) { + when (extension) { + is AnimeExtension.Available -> { + AsyncImage( + model = extension.iconUrl, + contentDescription = "", + placeholder = ColorPainter(Color(0x1F888888)), + error = bitmapPainterResource(id = R.drawable.cover_error), + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .then(defaultModifier), + ) + } + is AnimeExtension.Installed -> { + val icon by extension.getIcon(density) + when (icon) { + Result.Error -> Image( + bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_local_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + Result.Loading -> Box(modifier = modifier.then(defaultModifier)) + is Result.Success -> Image( + bitmap = (icon as Result.Success).value, + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } + } + is AnimeExtension.Untrusted -> Image( + bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } +} + +@Composable +private fun AnimeExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State> { + val context = LocalContext.current + return produceState>(initialValue = Result.Loading, this) { + withIOContext { + value = try { + val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) + val appResources = context.packageManager.getResourcesForApplication(appInfo) + Result.Success( + appResources.getDrawableForDensity(appInfo.icon, density, null)!! + .toBitmap() + .asImageBitmap(), + ) + } catch (e: Exception) { + Result.Error + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt new file mode 100644 index 0000000000..bd333228b6 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt @@ -0,0 +1,124 @@ +package eu.kanade.presentation.browse.components + +import android.content.pm.PackageManager +import android.util.DisplayMetrics +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import coil.compose.AsyncImage +import eu.kanade.domain.source.model.Source +import eu.kanade.presentation.util.bitmapPainterResource +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.util.lang.withIOContext + +private val defaultModifier = Modifier + .height(40.dp) + .aspectRatio(1f) + +@Composable +fun SourceIcon( + source: Source, + modifier: Modifier = Modifier, +) { + val icon = source.icon + + if (icon != null) { + Image( + bitmap = icon, + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } else { + Image( + painter = painterResource(id = R.mipmap.ic_local_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } +} + +@Composable +fun ExtensionIcon( + extension: Extension, + modifier: Modifier = Modifier, + density: Int = DisplayMetrics.DENSITY_DEFAULT, +) { + when (extension) { + is Extension.Available -> { + AsyncImage( + model = extension.iconUrl, + contentDescription = "", + placeholder = ColorPainter(Color(0x1F888888)), + error = bitmapPainterResource(id = R.drawable.cover_error), + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .then(defaultModifier), + ) + } + is Extension.Installed -> { + val icon by extension.getIcon(density) + when (icon) { + Result.Error -> Image( + bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_local_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + Result.Loading -> Box(modifier = modifier.then(defaultModifier)) + is Result.Success -> Image( + bitmap = (icon as Result.Success).value, + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } + } + is Extension.Untrusted -> Image( + bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } +} + +@Composable +private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State> { + val context = LocalContext.current + return produceState>(initialValue = Result.Loading, this) { + withIOContext { + value = try { + val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) + val appResources = context.packageManager.getResourcesForApplication(appInfo) + Result.Success( + appResources.getDrawableForDensity(appInfo.icon, density, null)!! + .toBitmap() + .asImageBitmap(), + ) + } catch (e: Exception) { + Result.Error + } + } + } +} + +sealed class Result { + object Loading : Result() + object Error : Result() + data class Success(val value: T) : Result() +} diff --git a/app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt b/app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt index e94bef8275..f43d056e90 100644 --- a/app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt @@ -29,7 +29,7 @@ fun EmptyScreen( ) { Box( modifier = Modifier - .fillMaxSize() + .fillMaxSize(), ) { AndroidView( factory = { context -> diff --git a/app/src/main/java/eu/kanade/presentation/components/LazyList.kt b/app/src/main/java/eu/kanade/presentation/components/LazyList.kt new file mode 100644 index 0000000000..62b004ab90 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/LazyList.kt @@ -0,0 +1,58 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.drawVerticalScrollbar + +/** + * LazyColumn with scrollbar. + */ +@Composable +fun ScrollbarLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit, +) { + val direction = LocalLayoutDirection.current + val density = LocalDensity.current + val positionOffset = remember(contentPadding) { + with(density) { contentPadding.calculateEndPadding(direction).toPx() } + } + LazyColumn( + modifier = modifier + .drawVerticalScrollbar( + state = state, + reverseScrolling = reverseLayout, + positionOffsetPx = positionOffset, + ), + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + content = content, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt b/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt index c949dcf8b2..3b2504d99f 100644 --- a/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt +++ b/app/src/main/java/eu/kanade/presentation/components/MangaCover.kt @@ -23,7 +23,7 @@ enum class MangaCover(private val ratio: Float) { modifier: Modifier = Modifier, data: String?, contentDescription: String? = null, - shape: Shape? = null + shape: Shape? = null, ) { AsyncImage( model = data, diff --git a/app/src/main/java/eu/kanade/presentation/components/Preferences.kt b/app/src/main/java/eu/kanade/presentation/components/Preferences.kt index d358f69cbb..774b5f6c84 100644 --- a/app/src/main/java/eu/kanade/presentation/components/Preferences.kt +++ b/app/src/main/java/eu/kanade/presentation/components/Preferences.kt @@ -21,13 +21,15 @@ import androidx.compose.ui.unit.dp import eu.kanade.core.prefs.PreferenceMutableState import eu.kanade.presentation.util.horizontalPadding +const val DIVIDER_ALPHA = 0.2f + @Composable fun Divider( modifier: Modifier = Modifier, ) { androidx.compose.material3.Divider( modifier = modifier, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = DIVIDER_ALPHA), ) } @@ -56,13 +58,13 @@ fun PreferenceRow( onLongClick = onLongClick, onClick = onClick, ), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { if (painter != null) { Icon( painter = painter, modifier = Modifier - .padding(horizontal = horizontalPadding) + .padding(start = horizontalPadding, end = 16.dp) .size(24.dp), tint = MaterialTheme.colorScheme.primary, contentDescription = null, @@ -70,8 +72,8 @@ fun PreferenceRow( } Column( Modifier - .padding(horizontal = horizontalPadding) - .weight(1f) + .padding(horizontal = 16.dp) + .weight(1f), ) { Text( text = title, @@ -86,7 +88,11 @@ fun PreferenceRow( } } if (action != null) { - Box(Modifier.widthIn(min = 56.dp)) { + Box( + Modifier + .widthIn(min = 56.dp) + .padding(end = horizontalPadding), + ) { action() } } @@ -106,11 +112,7 @@ fun SwitchPreference( title = title, subtitle = subtitle, painter = painter, - action = { - Switch(checked = preference.value, onCheckedChange = null) - // TODO: remove this once switch checked state is fixed: https://issuetracker.google.com/issues/228336571 - Text(preference.value.toString()) - }, + action = { Switch(checked = preference.value, onCheckedChange = null) }, onClick = { preference.value = !preference.value }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/components/SwipeRefresh.kt b/app/src/main/java/eu/kanade/presentation/components/SwipeRefresh.kt new file mode 100644 index 0000000000..b9a1bc2a9a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/SwipeRefresh.kt @@ -0,0 +1,17 @@ +package eu.kanade.presentation.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import com.google.accompanist.swiperefresh.SwipeRefreshState +import com.google.accompanist.swiperefresh.SwipeRefreshIndicator as AccompanistSwipeRefreshIndicator + +@Composable +fun SwipeRefreshIndicator(state: SwipeRefreshState, refreshTrigger: Dp) { + AccompanistSwipeRefreshIndicator( + state = state, + refreshTriggerDistance = refreshTrigger, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index 429e4bbfad..8b15227697 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars 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 @@ -27,6 +26,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -44,7 +44,10 @@ import eu.kanade.domain.history.model.HistoryWithRelations import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter @@ -88,7 +91,7 @@ fun HistoryContent( onClickResume: (HistoryWithRelations) -> Unit, onClickDelete: (HistoryWithRelations, Boolean) -> Unit, preferences: PreferencesHelper = Injekt.get(), - nestedScroll: NestedScrollConnection + nestedScroll: NestedScrollConnection, ) { if (history.loadState.refresh is LoadState.Loading) { LoadingScreen() @@ -101,13 +104,13 @@ fun HistoryContent( val relativeTime: Int = remember { preferences.relativeTime().get() } val dateFormat: DateFormat = remember { preferences.dateFormat() } - val (removeState, setRemoveState) = remember { mutableStateOf(null) } + var removeState by remember { mutableStateOf(null) } val scrollState = rememberLazyListState() - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier .nestedScroll(nestedScroll), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, state = scrollState, ) { items(history) { item -> @@ -118,7 +121,7 @@ fun HistoryContent( .animateItemPlacement(), date = item.date, relativeTime = relativeTime, - dateFormat = dateFormat + dateFormat = dateFormat, ) } is HistoryUiModel.Item -> { @@ -128,7 +131,7 @@ fun HistoryContent( history = value, onClickCover = { onClickCover(value) }, onClickResume = { onClickResume(value) }, - onClickDelete = { setRemoveState(value) }, + onClickDelete = { removeState = value }, ) } null -> {} @@ -139,10 +142,10 @@ fun HistoryContent( if (removeState != null) { RemoveHistoryDialog( onPositive = { all -> - onClickDelete(removeState, all) - setRemoveState(null) + onClickDelete(removeState!!, all) + removeState = null }, - onNegative = { setRemoveState(null) } + onNegative = { removeState = null }, ) } } @@ -160,12 +163,12 @@ fun HistoryHeader( text = date.toRelativeString( LocalContext.current, relativeTime, - dateFormat + dateFormat, ), style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, - ) + ), ) } @@ -200,7 +203,7 @@ fun HistoryItem( text = history.title, maxLines = 2, overflow = TextOverflow.Ellipsis, - style = textStyle.copy(fontWeight = FontWeight.SemiBold) + style = textStyle.copy(fontWeight = FontWeight.SemiBold), ) Row { Text( @@ -222,7 +225,7 @@ fun HistoryItem( IconButton(onClick = onClickDelete) { Icon( imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(id = R.string.action_delete), + contentDescription = stringResource(R.string.action_delete), tint = MaterialTheme.colorScheme.onSurface, ) } @@ -232,17 +235,17 @@ fun HistoryItem( @Composable fun RemoveHistoryDialog( onPositive: (Boolean) -> Unit, - onNegative: () -> Unit + onNegative: () -> Unit, ) { - val (removeEverything, removeEverythingState) = remember { mutableStateOf(false) } + var removeEverything by remember { mutableStateOf(false) } AlertDialog( title = { - Text(text = stringResource(id = R.string.action_remove)) + Text(text = stringResource(R.string.action_remove)) }, text = { Column { - Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description)) + Text(text = stringResource(R.string.dialog_with_checkbox_remove_description)) Row( modifier = Modifier .padding(top = 16.dp) @@ -250,9 +253,9 @@ fun RemoveHistoryDialog( interactionSource = remember { MutableInteractionSource() }, indication = null, value = removeEverything, - onValueChange = removeEverythingState + onValueChange = { removeEverything = it }, ), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = removeEverything, @@ -260,7 +263,7 @@ fun RemoveHistoryDialog( ) Text( modifier = Modifier.padding(start = 4.dp), - text = stringResource(id = R.string.dialog_with_checkbox_reset) + text = stringResource(R.string.dialog_with_checkbox_reset), ) } } @@ -268,12 +271,12 @@ fun RemoveHistoryDialog( onDismissRequest = onNegative, confirmButton = { TextButton(onClick = { onPositive(removeEverything) }) { - Text(text = stringResource(id = R.string.action_remove)) + Text(text = stringResource(R.string.action_remove)) } }, dismissButton = { TextButton(onClick = onNegative) { - Text(text = stringResource(id = R.string.action_cancel)) + Text(text = stringResource(R.string.action_cancel)) } }, ) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt b/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt index 1f00c52ba6..49795acaac 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt @@ -33,7 +33,7 @@ fun BaseMangaListItem( .clickable(onClick = onClickItem) .height(56.dp) .padding(horizontal = horizontalPadding), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { cover() content() @@ -47,7 +47,7 @@ private val defaultCover: @Composable RowScope.(Manga, () -> Unit) -> Unit = { m .padding(vertical = 8.dp) .clickable(onClick = onClick) .fillMaxHeight(), - data = manga.thumbnailUrl + data = manga.thumbnailUrl, ) } @@ -59,7 +59,7 @@ private val defaultContent: @Composable RowScope.(Manga) -> Unit = { .padding(start = horizontalPadding), overflow = TextOverflow.Ellipsis, maxLines = 1, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/more/LogoHeader.kt b/app/src/main/java/eu/kanade/presentation/more/LogoHeader.kt index 81b9a7efc5..bb006c09b5 100644 --- a/app/src/main/java/eu/kanade/presentation/more/LogoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/more/LogoHeader.kt @@ -18,7 +18,7 @@ import eu.kanade.tachiyomi.R fun LogoHeader() { Column { Surface( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(R.drawable.ic_ani), diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 91d5b39ff8..71aab0e2fe 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -3,7 +3,6 @@ package eu.kanade.presentation.more import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.CollectionsBookmark @@ -26,6 +25,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.components.SwitchPreference import eu.kanade.presentation.util.quantityStringResource import eu.kanade.tachiyomi.R @@ -53,7 +53,7 @@ fun MoreScreen( val preferences: PreferencesHelper = Injekt.get() - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt b/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt index 07dc68df0f..9a261b51d3 100644 --- a/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/about/AboutScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Public import androidx.compose.runtime.Composable @@ -20,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.LinkIcon import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.more.LogoHeader import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R @@ -37,7 +37,7 @@ fun AboutScreen( val context = LocalContext.current val uriHandler = LocalUriHandler.current - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt index c33070b3de..540dc6b64b 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/SettingsMainScreen.kt @@ -4,7 +4,6 @@ import androidx.annotation.StringRes import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -12,13 +11,14 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.PreferenceRow +import eu.kanade.presentation.components.ScrollbarLazyColumn @Composable fun SettingsMainScreen( nestedScrollInterop: NestedScrollConnection, sections: List, ) { - LazyColumn( + ScrollbarLazyColumn( modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/SettingsSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/SettingsSearchScreen.kt new file mode 100644 index 0000000000..dd8756e293 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/SettingsSearchScreen.kt @@ -0,0 +1,85 @@ +package eu.kanade.presentation.more.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.util.horizontalPadding +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchHelper +import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchPresenter +import kotlin.reflect.full.createInstance + +@Composable +fun SettingsSearchScreen( + nestedScroll: NestedScrollConnection, + presenter: SettingsSearchPresenter, + onClickResult: (SettingsController) -> Unit, +) { + val results by presenter.state.collectAsState() + + val scrollState = rememberLazyListState() + ScrollbarLazyColumn( + modifier = Modifier + .nestedScroll(nestedScroll), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + state = scrollState, + ) { + items( + items = results, + key = { it.key.toString() }, + ) { result -> + SearchResult(result, onClickResult) + } + } +} + +@Composable +private fun SearchResult( + result: SettingsSearchHelper.SettingsSearchResult, + onClickResult: (SettingsController) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = 8.dp) + .clickable { + // Must pass a new Controller instance to avoid this error + // https://github.com/bluelinelabs/Conductor/issues/446 + val controller = result.searchController::class.createInstance() + controller.preferenceKey = result.key + onClickResult(controller) + }, + ) { + Text( + text = result.title, + ) + + Text( + text = result.summary, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline, + ), + ) + + Text( + text = result.breadcrumb, + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Constants.kt b/app/src/main/java/eu/kanade/presentation/util/Constants.kt index fcf64d77b3..da958b5d61 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Constants.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Constants.kt @@ -1,5 +1,8 @@ package eu.kanade.presentation.util +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.unit.dp val horizontalPadding = 16.dp + +val topPaddingValues = PaddingValues(top = 8.dp) diff --git a/app/src/main/java/eu/kanade/presentation/util/PaddingValues.kt b/app/src/main/java/eu/kanade/presentation/util/PaddingValues.kt new file mode 100644 index 0000000000..b1a2a97846 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/PaddingValues.kt @@ -0,0 +1,20 @@ +package eu.kanade.presentation.util + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return PaddingValues( + start = calculateStartPadding(layoutDirection) + + other.calculateStartPadding(layoutDirection), + end = calculateEndPadding(layoutDirection) + + other.calculateEndPadding(layoutDirection), + top = calculateTopPadding() + other.calculateTopPadding(), + bottom = calculateBottomPadding() + other.calculateBottomPadding(), + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt b/app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt new file mode 100644 index 0000000000..6fa48d508b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Scrollbar.kt @@ -0,0 +1,257 @@ +package eu.kanade.presentation.util + +/* + * MIT License + * + * Copyright (c) 2022 Albert Chang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Code taken from https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a + * with some modifications to handle contentPadding. + * + * Modifiers for regular scrollable list is omitted. + */ + +import android.view.ViewConfiguration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.CacheDrawScope +import androidx.compose.ui.draw.DrawResult +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastSumBy +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest + +fun Modifier.drawHorizontalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false, + // The amount of offset the scrollbar position towards the top of the layout + positionOffsetPx: Float = 0f, +): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx) + +fun Modifier.drawVerticalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false, + // The amount of offset the scrollbar position towards the start of the layout + positionOffsetPx: Float = 0f, +): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx) + +private fun Modifier.drawScrollbar( + state: LazyListState, + orientation: Orientation, + reverseScrolling: Boolean, + positionOffset: Float, +): Modifier = drawScrollbar( + orientation, reverseScrolling, +) { reverseDirection, atEnd, thickness, color, alpha -> + val layoutInfo = state.layoutInfo + val viewportSize = if (orientation == Orientation.Horizontal) { + layoutInfo.viewportSize.width + } else { + layoutInfo.viewportSize.height + } - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding + val items = layoutInfo.visibleItemsInfo + val itemsSize = items.fastSumBy { it.size } + val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize + val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size + val totalSize = estimatedItemSize * layoutInfo.totalItemsCount + val thumbSize = viewportSize / totalSize * viewportSize + val startOffset = if (items.isEmpty()) 0f else items + .first() + .run { + val startPadding = if (reverseDirection) layoutInfo.afterContentPadding else layoutInfo.beforeContentPadding + startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize) + } + val drawScrollbar = onDrawScrollbar( + orientation, reverseDirection, atEnd, showScrollbar, + thickness, color, alpha, thumbSize, startOffset, positionOffset, + ) + onDrawWithContent { + drawContent() + drawScrollbar() + } +} + +private fun CacheDrawScope.onDrawScrollbar( + orientation: Orientation, + reverseDirection: Boolean, + atEnd: Boolean, + showScrollbar: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + thumbSize: Float, + scrollOffset: Float, + positionOffset: Float, +): DrawScope.() -> Unit { + val topLeft = if (orientation == Orientation.Horizontal) { + Offset( + if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset, + if (atEnd) size.height - positionOffset - thickness else positionOffset, + ) + } else { + Offset( + if (atEnd) size.width - positionOffset - thickness else positionOffset, + if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset, + ) + } + val size = if (orientation == Orientation.Horizontal) { + Size(thumbSize, thickness) + } else { + Size(thickness, thumbSize) + } + + return { + if (showScrollbar) { + drawRect( + color = color, + topLeft = topLeft, + size = size, + alpha = alpha(), + ) + } + } +} + +private fun Modifier.drawScrollbar( + orientation: Orientation, + reverseScrolling: Boolean, + onBuildDrawCache: CacheDrawScope.( + reverseDirection: Boolean, + atEnd: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + ) -> DrawResult, +): Modifier = composed { + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + val nestedScrollConnection = remember(orientation, scrolled) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y + if (delta != 0f) scrolled.tryEmit(Unit) + return Offset.Zero + } + } + } + + val alpha = remember { Animatable(0f) } + LaunchedEffect(scrolled, alpha) { + scrolled.collectLatest { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } + } + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val reverseDirection = if (orientation == Orientation.Horizontal) { + if (isLtr) reverseScrolling else !reverseScrolling + } else reverseScrolling + val atEnd = if (orientation == Orientation.Vertical) isLtr else true + + val context = LocalContext.current + val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() } + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f) + Modifier + .nestedScroll(nestedScrollConnection) + .drawWithCache { + onBuildDrawCache(reverseDirection, atEnd, thickness, color, alpha::value) + } +} + +private val FadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = ViewConfiguration.getScrollDefaultDelay(), +) + +@Preview(widthDp = 400, heightDp = 400, showBackground = true) +@Composable +fun LazyListScrollbarPreview() { + val state = rememberLazyListState() + LazyColumn( + modifier = Modifier.drawVerticalScrollbar(state), + state = state, + ) { + items(50) { + Text( + text = "Item ${it + 1}", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } +} + +@Preview(widthDp = 400, showBackground = true) +@Composable +fun LazyListHorizontalScrollbarPreview() { + val state = rememberLazyListState() + LazyRow( + modifier = Modifier.drawHorizontalScrollbar(state), + state = state, + ) { + items(50) { + Text( + text = (it + 1).toString(), + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index eb37d43c0e..8e2e8f98b6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -52,7 +52,7 @@ import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.security.Security -open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { +class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { private val preferences: PreferencesHelper by injectLazy() @@ -97,7 +97,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { this@App, 0, Intent(ACTION_DISABLE_INCOGNITO_MODE), - PendingIntent.FLAG_ONE_SHOT, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, ) setContentIntent(pendingIntent) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 367abe5aa4..2e921628cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -53,7 +53,7 @@ class AppModule(val app: Application) : InjektModule { .callback(DbOpenCallback()) .name(DbOpenCallback.DATABASE_NAME) .noBackupDirectory(false) - .build() + .build(), ) val openHelperAnime = FrameworkSQLiteOpenHelperFactory().create( @@ -61,7 +61,7 @@ class AppModule(val app: Application) : InjektModule { .callback(AnimeDbOpenCallback()) .name(AnimeDbOpenCallback.DATABASE_NAME) .noBackupDirectory(false) - .build() + .build(), ) val sqlDriverManga = AndroidSqliteDriver(openHelper = openHelperManga) @@ -72,12 +72,11 @@ class AppModule(val app: Application) : InjektModule { Database( driver = sqlDriverManga, historyAdapter = History.Adapter( - history_last_readAdapter = dateAdapter, - history_time_readAdapter = dateAdapter + last_readAdapter = dateAdapter, ), mangasAdapter = Mangas.Adapter( - genreAdapter = listOfStringsAdapter - ) + genreAdapter = listOfStringsAdapter, + ), ) } @@ -85,12 +84,11 @@ class AppModule(val app: Application) : InjektModule { AnimeDatabase( driver = sqlDriverAnime, animehistoryAdapter = Animehistory.Adapter( - animehistory_last_seenAdapter = dateAdapter, - animehistory_time_seenAdapter = dateAdapter + last_seenAdapter = dateAdapter, ), animesAdapter = Animes.Adapter( - genreAdapter = listOfStringsAdapter - ) + genreAdapter = listOfStringsAdapter, + ), ) } @@ -102,9 +100,9 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { PreferencesHelper(app) } - addSingletonFactory { DatabaseHelper(app, openHelperManga) } + addSingletonFactory { DatabaseHelper(openHelperManga) } - addSingletonFactory { AnimeDatabaseHelper(app, openHelperAnime) } + addSingletonFactory { AnimeDatabaseHelper(openHelperAnime) } addSingletonFactory { ChapterCache(app) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt index f931dbcc29..8bfda174d7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/AnimeSourceManager.kt @@ -14,7 +14,7 @@ import rx.Observable import tachiyomi.animesource.model.AnimeInfo import tachiyomi.animesource.model.EpisodeInfo -open class AnimeSourceManager(private val context: Context) { +class AnimeSourceManager(private val context: Context) { private val sourcesMap = mutableMapOf() private val stubSourcesMap = mutableMapOf() @@ -28,7 +28,7 @@ open class AnimeSourceManager(private val context: Context) { createInternalSources().forEach { registerSource(it) } } - open fun get(sourceKey: Long): AnimeSource? { + fun get(sourceKey: Long): AnimeSource? { return sourcesMap[sourceKey] } diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/LocalAnimeSource.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/LocalAnimeSource.kt index ba29619571..131a0c36a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/animesource/LocalAnimeSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/LocalAnimeSource.kt @@ -1,19 +1,26 @@ package eu.kanade.tachiyomi.animesource import android.content.Context +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFprobeKit +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.animesource.model.AnimeFilter import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.toAnimeInfo import eu.kanade.tachiyomi.animesource.model.toEpisodeInfo import eu.kanade.tachiyomi.animesource.model.toSAnime +import eu.kanade.tachiyomi.animesource.model.toSEpisode +import eu.kanade.tachiyomi.data.cache.AnimeCoverCache import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.util.episode.EpisodeRecognition import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.ImageUtil +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull @@ -24,110 +31,102 @@ import kotlinx.serialization.json.jsonPrimitive import rx.Observable import tachiyomi.animesource.model.AnimeInfo import tachiyomi.animesource.model.EpisodeInfo +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.File -import java.io.FileInputStream import java.io.InputStream import java.util.concurrent.TimeUnit -class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource, UnmeteredSource { - companion object { - const val ID = 0L - const val HELP_URL = "https://aniyomi.jmir.xyz/help/guides/local-anime/" - - private const val COVER_NAME = "cover.jpg" - private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) - - fun updateCover(context: Context, anime: SAnime, input: InputStream): File? { - val dir = getBaseDirectories(context).firstOrNull() - if (dir == null) { - input.close() - return null - } - var cover = getCoverFile(File("${dir.absolutePath}/${anime.url}")) - if (cover == null) { - cover = File("${dir.absolutePath}/${anime.url}", COVER_NAME) - } - // It might not exist if using the external SD card - cover.parentFile?.mkdirs() - input.use { - cover.outputStream().use { - input.copyTo(it) - } - } - // If no cover is set in the db - anime.thumbnail_url = cover.absolutePath - return cover - } - - /** - * Returns valid cover file inside [parent] directory. - */ - private fun getCoverFile(parent: File): File? { - return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { - it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } - } - } - - private fun getBaseDirectories(context: Context): List { - val c = context.getString(R.string.app_name) + File.separator + "localanime" - return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } - } - } +class LocalAnimeSource( + private val context: Context, + private val coverCache: AnimeCoverCache = Injekt.get(), +) : AnimeCatalogueSource, UnmeteredSource { private val json: Json by injectLazy() - override val id = ID override val name = context.getString(R.string.local_anime_source) + + override val id: Long = ID + override val lang = "other" - override val supportsLatest = true override fun toString() = name + override val supportsLatest = true + + // Browse related override fun fetchPopularAnime(page: Int) = fetchSearchAnime(page, "", POPULAR_FILTERS) + override fun fetchLatestUpdates(page: Int) = fetchSearchAnime(page, "", LATEST_FILTERS) + override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable { - val baseDirs = getBaseDirectories(context) - - val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L - var animeDirs = baseDirs - .asSequence() - .mapNotNull { it.listFiles()?.toList() } - .flatten() - .filter { it.isDirectory } - .filterNot { it.name.startsWith('.') } - .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + val baseDirsFiles = getBaseDirectoriesFiles(context) + + var animeDirs = baseDirsFiles + // Filter out files that are hidden and is not a folder + .filter { it.isDirectory && !it.name.startsWith('.') } .distinctBy { it.name } - val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state - when (state?.index) { - 0 -> { - animeDirs = if (state.ascending) { - animeDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it.name })) - } else { - animeDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it.name })) - } + val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L + // Filter by query or last modified + animeDirs = animeDirs.filter { + if (lastModifiedLimit == 0L) { + it.name.contains(query, ignoreCase = true) + } else { + it.lastModified() >= lastModifiedLimit } - 1 -> { - animeDirs = if (state.ascending) { - animeDirs.sortedBy(File::lastModified) - } else { - animeDirs.sortedByDescending(File::lastModified) + } + + filters.forEach { filter -> + when (filter) { + is OrderBy -> { + when (filter.state!!.index) { + 0 -> { + animeDirs = if (filter.state!!.ascending) { + animeDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } else { + animeDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + } + 1 -> { + animeDirs = if (filter.state!!.ascending) { + animeDirs.sortedBy(File::lastModified) + } else { + animeDirs.sortedByDescending(File::lastModified) + } + } + } } + + else -> { /* Do nothing */ } } } + // Transform animeDirs to list of SAnime val animes = animeDirs.map { animeDir -> SAnime.create().apply { title = animeDir.name url = animeDir.name // Try to find the cover - for (dir in baseDirs) { - val cover = getCoverFile(File("${dir.absolutePath}/$url")) - if (cover != null && cover.exists()) { - thumbnail_url = cover.absolutePath - break + val cover = getCoverFile(animeDir.name, baseDirsFiles) + if (cover != null && cover.exists()) { + thumbnail_url = cover.absolutePath + } + } + } + + // Fetch episodes of all the anime + animes.forEach { anime -> + val animeInfo = anime.toAnimeInfo() + runBlocking { + val episodes = getEpisodeList(animeInfo) + if (episodes.isNotEmpty()) { + val episode = episodes.last().toSEpisode() + // Copy the cover from the first episode found if not available + if (anime.thumbnail_url == null) { + updateCoverFromVideo(episode, anime) } } } @@ -136,19 +135,25 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource, Unm return Observable.just(AnimesPage(animes.toList(), false)) } - override fun fetchLatestUpdates(page: Int) = fetchSearchAnime(page, "", LATEST_FILTERS) - + // Anime details related override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo { - val localDetails = getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, anime.key).listFiles()?.toList() } - .flatten() + var animeInfo = anime + + val baseDirsFile = getBaseDirectoriesFiles(context) + + val coverFile = getCoverFile(anime.key, baseDirsFile) + + coverFile?.let { + animeInfo = animeInfo.copy(cover = it.absolutePath) + } + + val localDetails = getAnimeDirsFiles(anime.key, baseDirsFile) .firstOrNull { it.extension.equals("json", ignoreCase = true) } - return if (localDetails != null) { + if (localDetails != null) { val obj = json.decodeFromStream(localDetails.inputStream()) - anime.copy( + animeInfo = animeInfo.copy( title = obj["title"]?.jsonPrimitive?.contentOrNull ?: anime.title, author = obj["author"]?.jsonPrimitive?.contentOrNull ?: anime.author, artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: anime.artist, @@ -156,18 +161,18 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource, Unm genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: anime.genres, status = obj["status"]?.jsonPrimitive?.intOrNull ?: anime.status, ) - } else { - anime } + + return animeInfo } + // Episodes override suspend fun getEpisodeList(anime: AnimeInfo): List { val sAnime = anime.toSAnime() - val episodes = getBaseDirectories(context) - .asSequence() - .mapNotNull { file -> File(file, anime.key).listFiles()?.filter { isSupportedFile(it.extension) } } - .flatten() + val baseDirsFile = getBaseDirectoriesFiles(context) + return getAnimeDirsFiles(anime.key, baseDirsFile) + // Only keep supported formats .filter { it.isDirectory || isSupportedFile(it.extension) } .map { episodeFile -> SEpisode.create().apply { @@ -178,6 +183,7 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource, Unm episodeFile.nameWithoutExtension } date_upload = episodeFile.lastModified() + EpisodeRecognition.parseEpisodeNumber(this, sAnime) } } @@ -187,67 +193,113 @@ class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource, Unm if (e == 0) e2.name.compareToCaseInsensitiveNaturalOrder(e1.name) else e } .toList() - - return episodes } + // Filters + override fun getFilterList() = AnimeFilterList(OrderBy(context)) + + private val POPULAR_FILTERS = AnimeFilterList(OrderBy(context)) + private val LATEST_FILTERS = AnimeFilterList(OrderBy(context).apply { state = AnimeFilter.Sort.Selection(1, false) }) + + private class OrderBy(context: Context) : AnimeFilter.Sort( + context.getString(R.string.local_filter_order_by), + arrayOf(context.getString(R.string.title), context.getString(R.string.date)), + Selection(0, true), + ) + + // Unused stuff + override suspend fun getVideoList(episode: EpisodeInfo) = throw UnsupportedOperationException("Unused") + + // Miscellaneous private fun isSupportedFile(extension: String): Boolean { return extension.lowercase() in SUPPORTED_FILE_TYPES } - fun getFormat(episode: SEpisode): Format { - val baseDirs = getBaseDirectories(context) + private fun updateCoverFromVideo(episode: SEpisode, anime: SAnime) { + val baseDirsFiles = getBaseDirectoriesFiles(context) + val animeDir = getAnimeDir(anime.url, baseDirsFiles) ?: return + val coverPath = "${animeDir.absolutePath}/$DEFAULT_COVER_NAME" + + val ffProbe = FFprobeKit.execute("-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '${episode.url}'") + val duration = ffProbe.allLogsAsString.trim().toFloat() + val second = duration.toInt() / 2 + FFmpegKit.execute("-ss $second -i '${episode.url}' -frames 1 -q:v 2 '$coverPath'") + anime.thumbnail_url = coverPath + coverCache.clearMemoryCache() + } + + companion object { + const val ID = 0L + const val HELP_URL = "https://aniyomi.jmir.xyz/help/guides/local-anime/" - for (dir in baseDirs) { - val episodeFile = File(dir, episode.url) - if (!episodeFile.exists()) continue + private const val DEFAULT_COVER_NAME = "cover.jpg" + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) - return getFormat(episodeFile) + private fun getBaseDirectories(context: Context): Sequence { + val localFolder = context.getString(R.string.app_name) + File.separator + "localanime" + return DiskUtil.getExternalStorages(context) + .map { File(it.absolutePath, localFolder) } + .asSequence() } - throw Exception(context.getString(R.string.episode_not_found)) - } - private fun getFormat(file: File) = with(file) { - when { - isDirectory -> Format.Directory(this) - isSupportedFile(extension) -> Format.Anime(this.parentFile!!) - else -> throw Exception(context.getString(R.string.local_invalid_episode_format)) + private fun getBaseDirectoriesFiles(context: Context): Sequence { + return getBaseDirectories(context) + // Get all the files inside all baseDir + .flatMap { it.listFiles().orEmpty().toList() } } - } - private fun updateCover(episode: SEpisode, anime: SAnime): File? { - return when (val format = getFormat(episode)) { - is Format.Directory -> { - val entry = format.file.listFiles() - ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + private fun getAnimeDir(animeUrl: String, baseDirsFile: Sequence): File? { + return baseDirsFile + // Get the first animeDir or null + .firstOrNull { it.isDirectory && it.name == animeUrl } + } - entry?.let { updateCover(context, anime, it.inputStream()) } - } - is Format.Anime -> { - val entry = format.file.listFiles() - ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + private fun getAnimeDirsFiles(animeUrl: String, baseDirsFile: Sequence): Sequence { + return baseDirsFile + // Filter out ones that are not related to the anime and is not a directory + .filter { it.isDirectory && it.name == animeUrl } + // Get all the files inside the filtered folders + .flatMap { it.listFiles().orEmpty().toList() } + } - entry?.let { updateCover(context, anime, it.inputStream()) } - } + private fun getCoverFile(animeUrl: String, baseDirsFile: Sequence): File? { + return getAnimeDirsFiles(animeUrl, baseDirsFile) + // Get all file whose names start with 'cover' + .filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) } + // Get the first actual image + .firstOrNull { + ImageUtil.isImage(it.name) { it.inputStream() } + } } - } - override fun getFilterList() = POPULAR_FILTERS + fun updateCover(context: Context, anime: SAnime, inputStream: InputStream): File? { + val baseDirsFiles = getBaseDirectoriesFiles(context) - private val POPULAR_FILTERS = AnimeFilterList(OrderBy(context)) - private val LATEST_FILTERS = AnimeFilterList(OrderBy(context).apply { state = AnimeFilter.Sort.Selection(1, false) }) + val animeDir = getAnimeDir(anime.url, baseDirsFiles) + if (animeDir == null) { + inputStream.close() + return null + } - private class OrderBy(context: Context) : AnimeFilter.Sort( - context.getString(R.string.local_filter_order_by), - arrayOf(context.getString(R.string.title), context.getString(R.string.date)), - Selection(0, true), - ) + var coverFile = getCoverFile(anime.url, baseDirsFiles) + if (coverFile == null) { + coverFile = File(animeDir.absolutePath, DEFAULT_COVER_NAME) + } - sealed class Format { - data class Directory(val file: File) : Format() - data class Anime(val file: File) : Format() + // It might not exist at this point + coverFile.parentFile?.mkdirs() + inputStream.use { input -> + coverFile.outputStream().use { output -> + input.copyTo(output) + } + } + + // Create a .nomedia file + DiskUtil.createNoMediaFile(UniFile.fromFile(animeDir), context) + + anime.thumbnail_url = coverFile.absolutePath + return coverFile + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateJob.kt index 25bf603464..44264e3078 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateJob.kt @@ -10,6 +10,7 @@ import androidx.work.Worker import androidx.work.WorkerParameters import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING +import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.util.system.isConnectedToWifi @@ -22,8 +23,9 @@ class AnimelibUpdateJob(private val context: Context, workerParams: WorkerParame override fun doWork(): Result { val preferences = Injekt.get() - if (requiresWifiConnection(preferences) && !context.isConnectedToWifi()) { - Result.failure() + val restrictions = preferences.libraryUpdateDeviceRestriction().get() + if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { + return Result.failure() } return if (AnimelibUpdateService.start(context)) { @@ -42,7 +44,7 @@ class AnimelibUpdateJob(private val context: Context, workerParams: WorkerParame if (interval > 0) { val restrictions = preferences.libraryUpdateDeviceRestriction().get() val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED }) .setRequiresCharging(DEVICE_CHARGING in restrictions) .setRequiresBatteryNotLow(DEVICE_BATTERY_NOT_LOW in restrictions) .build() @@ -62,10 +64,5 @@ class AnimelibUpdateJob(private val context: Context, workerParams: WorkerParame WorkManager.getInstance(context).cancelAllWorkByTag(TAG) } } - - fun requiresWifiConnection(preferences: PreferencesHelper): Boolean { - val restrictions = preferences.libraryUpdateDeviceRestriction().get() - return DEVICE_ONLY_ON_WIFI in restrictions - } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt index 4636257467..e336ff1fed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt @@ -339,7 +339,7 @@ class AnimelibUpdateNotifier(private val context: Context) { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP action = MainActivity.SHORTCUT_RECENTLY_UPDATED } - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt index 3960352e20..16f9f99ec3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt @@ -20,16 +20,17 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.toSChapter import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource import eu.kanade.tachiyomi.util.episode.syncEpisodesWithSource -import uy.kohesive.injekt.injectLazy +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get abstract class AbstractBackupManager(protected val context: Context) { - internal val databaseHelper: DatabaseHelper by injectLazy() - internal val animedatabaseHelper: AnimeDatabaseHelper by injectLazy() - internal val sourceManager: SourceManager by injectLazy() - internal val animesourceManager: AnimeSourceManager by injectLazy() - internal val trackManager: TrackManager by injectLazy() - protected val preferences: PreferencesHelper by injectLazy() + internal val db: DatabaseHelper = Injekt.get() + internal val animedb: AnimeDatabaseHelper = Injekt.get() + internal val sourceManager: SourceManager = Injekt.get() + internal val animesourceManager: AnimeSourceManager = Injekt.get() + internal val trackManager: TrackManager = Injekt.get() + protected val preferences: PreferencesHelper = Injekt.get() abstract fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String @@ -39,7 +40,7 @@ abstract class AbstractBackupManager(protected val context: Context) { * @return [Manga], null if not found */ internal fun getMangaFromDatabase(manga: Manga): Manga? = - databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() + db.getManga(manga.url, manga.source).executeAsBlocking() /** * Returns manga @@ -47,7 +48,7 @@ abstract class AbstractBackupManager(protected val context: Context) { * @return [Manga], null if not found */ internal fun getAnimeFromDatabase(anime: Anime): Anime? = - animedatabaseHelper.getAnime(anime.url, anime.source).executeAsBlocking() + animedb.getAnime(anime.url, anime.source).executeAsBlocking() /** * Fetches chapter information. @@ -60,7 +61,7 @@ abstract class AbstractBackupManager(protected val context: Context) { internal suspend fun restoreChapters(source: Source, manga: Manga, chapters: List): Pair, List> { val fetchedChapters = source.getChapterList(manga.toMangaInfo()) .map { it.toSChapter() } - val syncedChapters = syncChaptersWithSource(databaseHelper, fetchedChapters, manga, source) + val syncedChapters = syncChaptersWithSource(db, fetchedChapters, manga, source) if (syncedChapters.first.isNotEmpty()) { chapters.forEach { it.manga_id = manga.id } updateChapters(chapters) @@ -79,7 +80,7 @@ abstract class AbstractBackupManager(protected val context: Context) { internal suspend fun restoreEpisodes(source: AnimeSource, anime: Anime, episodes: List): Pair, List> { val fetchedEpisodes = source.getEpisodeList(anime.toAnimeInfo()) .map { it.toSEpisode() } - val syncedEpisodes = syncEpisodesWithSource(animedatabaseHelper, fetchedEpisodes, anime, source) + val syncedEpisodes = syncEpisodesWithSource(animedb, fetchedEpisodes, anime, source) if (syncedEpisodes.first.isNotEmpty()) { episodes.forEach { it.anime_id = anime.id } updateEpisodes(episodes) @@ -93,7 +94,7 @@ abstract class AbstractBackupManager(protected val context: Context) { * @return [Manga] from library */ protected fun getFavoriteManga(): List = - databaseHelper.getFavoriteMangas().executeAsBlocking() + db.getFavoriteMangas().executeAsBlocking() /** * Returns list containing anime from library @@ -101,7 +102,7 @@ abstract class AbstractBackupManager(protected val context: Context) { * @return [Anime] from library */ protected fun getFavoriteAnime(): List = - animedatabaseHelper.getFavoriteAnimes().executeAsBlocking() + animedb.getFavoriteAnimes().executeAsBlocking() /** * Inserts manga and returns id @@ -109,27 +110,27 @@ abstract class AbstractBackupManager(protected val context: Context) { * @return id of [Manga], null if not found */ internal fun insertManga(manga: Manga): Long? = - databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + db.insertManga(manga).executeAsBlocking().insertedId() /** * Inserts list of chapters */ protected fun insertChapters(chapters: List) { - databaseHelper.insertChapters(chapters).executeAsBlocking() + db.insertChapters(chapters).executeAsBlocking() } /** * Updates a list of chapters */ protected fun updateChapters(chapters: List) { - databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() + db.updateChaptersBackup(chapters).executeAsBlocking() } /** * Updates a list of chapters with known database ids */ protected fun updateKnownChapters(chapters: List) { - databaseHelper.updateKnownChaptersBackup(chapters).executeAsBlocking() + db.updateKnownChaptersBackup(chapters).executeAsBlocking() } /** @@ -138,27 +139,27 @@ abstract class AbstractBackupManager(protected val context: Context) { * @return id of [Anime], null if not found */ internal fun insertAnime(anime: Anime): Long? = - animedatabaseHelper.insertAnime(anime).executeAsBlocking().insertedId() + animedb.insertAnime(anime).executeAsBlocking().insertedId() /** * Inserts list of chapters */ protected fun insertEpisodes(episodes: List) { - animedatabaseHelper.insertEpisodes(episodes).executeAsBlocking() + animedb.insertEpisodes(episodes).executeAsBlocking() } /** * Updates a list of chapters */ protected fun updateEpisodes(episodes: List) { - animedatabaseHelper.updateEpisodesBackup(episodes).executeAsBlocking() + animedb.updateEpisodesBackup(episodes).executeAsBlocking() } /** * Updates a list of chapters with known database ids */ protected fun updateKnownEpisodes(episodes: List) { - animedatabaseHelper.updateKnownEpisodesBackup(episodes).executeAsBlocking() + animedb.updateKnownEpisodesBackup(episodes).executeAsBlocking() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt index 5fc47fcf05..b8d8f65dc8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt @@ -2,19 +2,9 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context import android.net.Uri -import eu.kanade.tachiyomi.animesource.AnimeSourceManager -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.SourceManager -import uy.kohesive.injekt.injectLazy abstract class AbstractBackupRestoreValidator { - protected val sourceManager: SourceManager by injectLazy() - protected val animesourceManager: AnimeSourceManager by injectLazy() - protected val trackManager: TrackManager by injectLazy() - abstract fun validate(context: Context, uri: Uri): Results data class Results(val missingSources: List, val missingTrackers: List) } - -class ValidatorParseException(e: Exception) : RuntimeException(e) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt index 4438a8d7ea..77a7db1a53 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt @@ -6,11 +6,6 @@ object BackupConst { private const val NAME = "BackupRestoreServices" const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" - const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" - const val EXTRA_MODE = "$ID.$NAME.EXTRA_MODE" - - const val BACKUP_TYPE_LEGACY = 0 - const val BACKUP_TYPE_FULL = 1 // Filter options internal const val BACKUP_CATEGORY = 0x1 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index 04a1e8fae2..1fde0edc36 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -73,7 +73,7 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet workDataOf( IS_AUTO_BACKUP_KEY to true, BACKUP_FLAGS_KEY to flags, - ) + ), ) .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt index 8b912a8056..037efc6a95 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt @@ -9,7 +9,6 @@ import android.os.PowerManager import androidx.core.content.ContextCompat import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.full.FullBackupRestore -import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestore import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.isServiceRunning @@ -44,11 +43,10 @@ class BackupRestoreService : Service() { * @param context context of application * @param uri path of Uri */ - fun start(context: Context, uri: Uri, mode: Int) { + fun start(context: Context, uri: Uri) { if (!isRunning(context)) { val intent = Intent(context, BackupRestoreService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) - putExtra(BackupConst.EXTRA_MODE, mode) } ContextCompat.startForegroundService(context, intent) } @@ -118,15 +116,11 @@ class BackupRestoreService : Service() { */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val uri = intent?.getParcelableExtra(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY - val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL) // Cancel any previous job if needed. backupRestore?.job?.cancel() - backupRestore = when (mode) { - BackupConst.BACKUP_TYPE_FULL -> FullBackupRestore(this, notifier) - else -> LegacyBackupRestore(this, notifier) - } + backupRestore = FullBackupRestore(this, notifier) val handler = CoroutineExceptionHandler { _, exception -> logcat(LogPriority.ERROR, exception) 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 8b8128034f..8c778b2ab4 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 @@ -71,8 +71,8 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Create root object var backup: Backup? = null - animedatabaseHelper.inTransaction { - databaseHelper.inTransaction { + animedb.inTransaction { + db.inTransaction { val databaseManga = getFavoriteManga() val databaseAnime = getFavoriteAnime() @@ -183,7 +183,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { private fun backupCategories(options: Int): List { // Check if user wants category information in backup return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { - databaseHelper.getCategories() + db.getCategories() .executeAsBlocking() .map { BackupCategory.copyFrom(it) } } else { @@ -199,7 +199,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { private fun backupCategoriesAnime(options: Int): List { // Check if user wants category information in backup return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { - animedatabaseHelper.getCategories() + animedb.getCategories() .executeAsBlocking() .map { BackupCategory.copyFrom(it) } } else { @@ -221,7 +221,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants chapter information in backup if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { // Backup all the chapters - val chapters = databaseHelper.getChapters(manga).executeAsBlocking() + val chapters = db.getChapters(manga).executeAsBlocking() if (chapters.isNotEmpty()) { mangaObject.chapters = chapters.map { BackupChapter.copyFrom(it) } } @@ -230,7 +230,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants category information in backup if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { // Backup categories for this manga - val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking() + val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking() if (categoriesForManga.isNotEmpty()) { mangaObject.categories = categoriesForManga.mapNotNull { it.order } } @@ -238,7 +238,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants track information in backup if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { - val tracks = databaseHelper.getTracks(manga).executeAsBlocking() + val tracks = db.getTracks(manga).executeAsBlocking() if (tracks.isNotEmpty()) { mangaObject.tracking = tracks.map { BackupTracking.copyFrom(it) } } @@ -246,10 +246,10 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants history information in backup if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { - val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() + val historyForManga = db.getHistoryByMangaId(manga.id!!).executeAsBlocking() if (historyForManga.isNotEmpty()) { val history = historyForManga.mapNotNull { history -> - val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url + val url = db.getChapter(history.chapter_id).executeAsBlocking()?.url url?.let { BackupHistory(url, history.last_read) } } if (history.isNotEmpty()) { @@ -275,7 +275,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants chapter information in backup if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { // Backup all the chapters - val episodes = animedatabaseHelper.getEpisodes(anime).executeAsBlocking() + val episodes = animedb.getEpisodes(anime).executeAsBlocking() if (episodes.isNotEmpty()) { animeObject.episodes = episodes.map { BackupEpisode.copyFrom(it) } } @@ -284,7 +284,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants category information in backup if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { // Backup categories for this manga - val categoriesForAnime = animedatabaseHelper.getCategoriesForAnime(anime).executeAsBlocking() + val categoriesForAnime = animedb.getCategoriesForAnime(anime).executeAsBlocking() if (categoriesForAnime.isNotEmpty()) { animeObject.categories = categoriesForAnime.mapNotNull { it.order } } @@ -292,7 +292,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants track information in backup if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { - val tracks = animedatabaseHelper.getTracks(anime).executeAsBlocking() + val tracks = animedb.getTracks(anime).executeAsBlocking() if (tracks.isNotEmpty()) { animeObject.tracking = tracks.map { BackupAnimeTracking.copyFrom(it) } } @@ -300,10 +300,10 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Check if user wants history information in backup if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { - val historyForAnime = animedatabaseHelper.getHistoryByAnimeId(anime.id!!).executeAsBlocking() + val historyForAnime = animedb.getHistoryByAnimeId(anime.id!!).executeAsBlocking() if (historyForAnime.isNotEmpty()) { val history = historyForAnime.mapNotNull { history -> - val url = animedatabaseHelper.getEpisode(history.episode_id).executeAsBlocking()?.url + val url = animedb.getEpisode(history.episode_id).executeAsBlocking()?.url url?.let { BackupAnimeHistory(url, history.last_seen) } } if (history.isNotEmpty()) { @@ -394,7 +394,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { */ internal fun restoreCategories(backupCategories: List) { // Get categories from file and from db - val dbCategories = databaseHelper.getCategories().executeAsBlocking() + val dbCategories = db.getCategories().executeAsBlocking() // Iterate over them backupCategories.map { it.getCategoryImpl() }.forEach { category -> @@ -414,7 +414,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { if (!found) { // Let the db assign the id category.id = null - val result = databaseHelper.insertCategory(category).executeAsBlocking() + val result = db.insertCategory(category).executeAsBlocking() category.id = result.insertedId()?.toInt() } } @@ -427,7 +427,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { */ internal fun restoreCategoriesAnime(backupCategories: List) { // Get categories from file and from db - val dbCategories = animedatabaseHelper.getCategories().executeAsBlocking() + val dbCategories = animedb.getCategories().executeAsBlocking() // Iterate over them backupCategories.map { it.getCategoryImpl() }.forEach { category -> @@ -447,7 +447,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { if (!found) { // Let the db assign the id category.id = null - val result = animedatabaseHelper.insertCategory(category).executeAsBlocking() + val result = animedb.insertCategory(category).executeAsBlocking() category.id = result.insertedId()?.toInt() } } @@ -460,7 +460,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { * @param categories the categories to restore. */ internal fun restoreCategoriesForManga(manga: Manga, categories: List, backupCategories: List) { - val dbCategories = databaseHelper.getCategories().executeAsBlocking() + val dbCategories = db.getCategories().executeAsBlocking() val mangaCategoriesToUpdate = ArrayList(categories.size) categories.forEach { backupCategoryOrder -> backupCategories.firstOrNull { @@ -476,8 +476,8 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Update database if (mangaCategoriesToUpdate.isNotEmpty()) { - databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking() - databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() + db.deleteOldMangasCategories(listOf(manga)).executeAsBlocking() + db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() } } @@ -488,7 +488,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { * @param categories the categories to restore. */ internal fun restoreCategoriesForAnime(anime: Anime, categories: List, backupCategories: List) { - val dbCategories = animedatabaseHelper.getCategories().executeAsBlocking() + val dbCategories = animedb.getCategories().executeAsBlocking() val animeCategoriesToUpdate = ArrayList(categories.size) categories.forEach { backupCategoryOrder -> backupCategories.firstOrNull { @@ -504,8 +504,8 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // Update database if (animeCategoriesToUpdate.isNotEmpty()) { - animedatabaseHelper.deleteOldAnimesCategories(listOf(anime)).executeAsBlocking() - animedatabaseHelper.insertAnimesCategories(animeCategoriesToUpdate).executeAsBlocking() + animedb.deleteOldAnimesCategories(listOf(anime)).executeAsBlocking() + animedb.insertAnimesCategories(animeCategoriesToUpdate).executeAsBlocking() } } @@ -518,7 +518,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // List containing history to be updated val historyToBeUpdated = ArrayList(history.size) for ((url, lastRead) in history) { - val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking() + val dbHistory = db.getHistoryByChapterUrl(url).executeAsBlocking() // Check if history already in database and update if (dbHistory != null) { dbHistory.apply { @@ -527,7 +527,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { historyToBeUpdated.add(dbHistory) } else { // If not in database create - databaseHelper.getChapter(url).executeAsBlocking()?.let { + db.getChapter(url).executeAsBlocking()?.let { val historyToAdd = History.create(it).apply { last_read = lastRead } @@ -535,7 +535,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { } } } - databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking() + db.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking() } /** @@ -547,7 +547,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { // List containing history to be updated val historyToBeUpdated = ArrayList(history.size) for ((url, lastSeen) in history) { - val dbHistory = animedatabaseHelper.getHistoryByEpisodeUrl(url).executeAsBlocking() + val dbHistory = animedb.getHistoryByEpisodeUrl(url).executeAsBlocking() // Check if history already in database and update if (dbHistory != null) { dbHistory.apply { @@ -556,7 +556,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { historyToBeUpdated.add(dbHistory) } else { // If not in database create - animedatabaseHelper.getEpisode(url).executeAsBlocking()?.let { + animedb.getEpisode(url).executeAsBlocking()?.let { val historyToAdd = AnimeHistory.create(it).apply { last_seen = lastSeen } @@ -564,7 +564,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { } } } - animedatabaseHelper.upsertAnimeHistoryLastSeen(historyToBeUpdated).executeAsBlocking() + animedb.upsertAnimeHistoryLastSeen(historyToBeUpdated).executeAsBlocking() } /** @@ -578,7 +578,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { tracks.map { it.manga_id = manga.id!! } // Get tracks from database - val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() + val dbTracks = db.getTracks(manga).executeAsBlocking() val trackToUpdate = mutableListOf() tracks.forEach { track -> @@ -606,7 +606,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { } // Update database if (trackToUpdate.isNotEmpty()) { - databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() + db.insertTracks(trackToUpdate).executeAsBlocking() } } @@ -621,7 +621,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { tracks.map { it.anime_id = anime.id!! } // Get tracks from database - val dbTracks = animedatabaseHelper.getTracks(anime).executeAsBlocking() + val dbTracks = animedb.getTracks(anime).executeAsBlocking() val trackToUpdate = mutableListOf() tracks.forEach { track -> @@ -649,12 +649,12 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { } // Update database if (trackToUpdate.isNotEmpty()) { - animedatabaseHelper.insertTracks(trackToUpdate).executeAsBlocking() + animedb.insertTracks(trackToUpdate).executeAsBlocking() } } internal fun restoreChaptersForManga(manga: Manga, chapters: List) { - val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() + val dbChapters = db.getChapters(manga).executeAsBlocking() chapters.forEach { chapter -> val dbChapter = dbChapters.find { it.url == chapter.url } @@ -681,7 +681,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) { } internal fun restoreEpisodesForAnime(anime: Anime, episodes: List) { - val dbEpisodes = animedatabaseHelper.getEpisodes(anime).executeAsBlocking() + val dbEpisodes = animedb.getEpisodes(anime).executeAsBlocking() episodes.forEach { episode -> val dbEpisode = dbEpisodes.find { it.url == episode.url } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt index ccf7afd3c3..d732655e6d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt @@ -55,8 +55,8 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBa // Store source mapping for error messages val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources val backupMapsAnime = backup.backupBrokenAnimeSources.map { BackupAnimeSource(it.name, it.sourceId) } + backup.backupAnimeSources - sourceMapping = backupMaps.map { it.sourceId to it.name }.toMap() + - backupMapsAnime.map { it.sourceId to it.name }.toMap() + sourceMapping = backupMaps.associate { it.sourceId to it.name } + + backupMapsAnime.associate { it.sourceId to it.name } // Restore individual manga backup.backupManga.forEach { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt index 311bb37616..42daad5b2d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt @@ -3,15 +3,23 @@ package eu.kanade.tachiyomi.data.backup.full import android.content.Context import android.net.Uri import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator -import eu.kanade.tachiyomi.data.backup.ValidatorParseException import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.SourceManager import okio.buffer import okio.gzip import okio.source +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class FullBackupRestoreValidator : AbstractBackupRestoreValidator() { + private val sourceManager: SourceManager = Injekt.get() + private val animesourceManager: AnimeSourceManager = Injekt.get() + private val trackManager: TrackManager = Injekt.get() + /** * Checks for critical backup file data. * @@ -27,11 +35,11 @@ class FullBackupRestoreValidator : AbstractBackupRestoreValidator() { .use { it.readByteArray() } backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) } catch (e: Exception) { - throw ValidatorParseException(e) + throw IllegalStateException(e) } if (backup.backupManga.isEmpty() && backup.backupAnime.isEmpty()) { - throw Exception(context.getString(R.string.invalid_backup_file_missing_manga)) + throw IllegalStateException(context.getString(R.string.invalid_backup_file_missing_manga)) } val sources = backup.backupSources.associate { it.sourceId to it.name } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupAnimeTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupAnimeTracking.kt index e53eb34983..e2c5a91c9e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupAnimeTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupAnimeTracking.kt @@ -12,7 +12,8 @@ data class BackupAnimeTracking( @ProtoNumber(1) var syncId: Int, // LibraryId is not null in 1.x @ProtoNumber(2) var libraryId: Long, - @ProtoNumber(3) var mediaId: Int = 0, + @Deprecated("Use mediaId instead", level = DeprecationLevel.WARNING) @ProtoNumber(3) + var mediaIdInt: Int = 0, // trackingUrl is called mediaUrl in 1.x @ProtoNumber(4) var trackingUrl: String = "", @ProtoNumber(5) var title: String = "", @@ -25,11 +26,16 @@ data class BackupAnimeTracking( @ProtoNumber(10) var startedWatchingDate: Long = 0, // finishedReadingDate is called endReadTime in 1.x @ProtoNumber(11) var finishedWatchingDate: Long = 0, + @ProtoNumber(100) var mediaId: Long = 0, ) { fun getTrackingImpl(): AnimeTrackImpl { return AnimeTrackImpl().apply { sync_id = this@BackupAnimeTracking.syncId - media_id = this@BackupAnimeTracking.mediaId + media_id = if (this@BackupAnimeTracking.mediaIdInt != 0) { + this@BackupAnimeTracking.mediaIdInt.toLong() + } else { + this@BackupAnimeTracking.mediaId + } library_id = this@BackupAnimeTracking.libraryId title = this@BackupAnimeTracking.title last_episode_seen = this@BackupAnimeTracking.lastChapterRead diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt index 2ef022d5d5..5e45f86635 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt @@ -12,7 +12,8 @@ data class BackupTracking( @ProtoNumber(1) var syncId: Int, // LibraryId is not null in 1.x @ProtoNumber(2) var libraryId: Long, - @ProtoNumber(3) var mediaId: Int = 0, + @Deprecated("Use mediaId instead", level = DeprecationLevel.WARNING) @ProtoNumber(3) + var mediaIdInt: Int = 0, // trackingUrl is called mediaUrl in 1.x @ProtoNumber(4) var trackingUrl: String = "", @ProtoNumber(5) var title: String = "", @@ -25,11 +26,17 @@ data class BackupTracking( @ProtoNumber(10) var startedReadingDate: Long = 0, // finishedReadingDate is called endReadTime in 1.x @ProtoNumber(11) var finishedReadingDate: Long = 0, + @ProtoNumber(100) var mediaId: Long = 0, ) { + fun getTrackingImpl(): TrackImpl { return TrackImpl().apply { sync_id = this@BackupTracking.syncId - media_id = this@BackupTracking.mediaId + media_id = if (this@BackupTracking.mediaIdInt != 0) { + this@BackupTracking.mediaIdInt.toLong() + } else { + this@BackupTracking.mediaId + } library_id = this@BackupTracking.libraryId title = this@BackupTracking.title last_chapter_read = this@BackupTracking.lastChapterRead 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 deleted file mode 100644 index 8d42245e02..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt +++ /dev/null @@ -1,252 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy - -import android.content.Context -import android.net.Uri -import eu.kanade.tachiyomi.data.backup.AbstractBackupManager -import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.Companion.CURRENT_VERSION -import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory -import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryImplTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterImplTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaImplTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackImplTypeSerializer -import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackTypeSerializer -import eu.kanade.tachiyomi.data.database.models.Category -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.MangaCategory -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.models.toMangaInfo -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.model.toSManga -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.contextual -import kotlin.math.max - -class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) { - - val parser: Json = when (version) { - 2 -> Json { - // Forks may have added items to backup - ignoreUnknownKeys = true - - // Register custom serializers - serializersModule = SerializersModule { - contextual(MangaTypeSerializer) - contextual(MangaImplTypeSerializer) - contextual(ChapterTypeSerializer) - contextual(ChapterImplTypeSerializer) - contextual(CategoryTypeSerializer) - contextual(CategoryImplTypeSerializer) - contextual(TrackTypeSerializer) - contextual(TrackImplTypeSerializer) - contextual(HistoryTypeSerializer) - } - } - else -> throw Exception("Unknown backup version") - } - - /** - * Create backup Json file from database - * - * @param uri path of Uri - * @param isAutoBackup backup called from scheduled backup job - */ - override fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean) = - throw IllegalStateException("Legacy backup creation is not supported") - - fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) { - manga.id = dbManga.id - manga.copyFrom(dbManga) - manga.favorite = true - insertManga(manga) - } - - /** - * Fetches manga information - * - * @param source source of manga - * @param manga manga that needs updating - * @return Updated manga. - */ - suspend fun fetchManga(source: Source, manga: Manga): Manga { - val networkManga = source.getMangaDetails(manga.toMangaInfo()) - return manga.also { - it.copyFrom(networkManga.toSManga()) - it.favorite = true - it.initialized = true - it.id = insertManga(manga) - } - } - - /** - * Restore the categories from Json - * - * @param backupCategories array containing categories - */ - internal fun restoreCategories(backupCategories: List) { - // Get categories from file and from db - val dbCategories = databaseHelper.getCategories().executeAsBlocking() - - // Iterate over them - backupCategories.forEach { category -> - // Used to know if the category is already in the db - var found = false - for (dbCategory in dbCategories) { - // If the category is already in the db, assign the id to the file's category - // and do nothing - if (category.name == dbCategory.name) { - category.id = dbCategory.id - found = true - break - } - } - // If the category isn't in the db, remove the id and insert a new category - // Store the inserted id in the category - if (!found) { - // Let the db assign the id - category.id = null - val result = databaseHelper.insertCategory(category).executeAsBlocking() - category.id = result.insertedId()?.toInt() - } - } - } - - /** - * Restores the categories a manga is in. - * - * @param manga the manga whose categories have to be restored. - * @param categories the categories to restore. - */ - internal fun restoreCategoriesForManga(manga: Manga, categories: List) { - val dbCategories = databaseHelper.getCategories().executeAsBlocking() - val mangaCategoriesToUpdate = ArrayList(categories.size) - for (backupCategoryStr in categories) { - for (dbCategory in dbCategories) { - if (backupCategoryStr == dbCategory.name) { - mangaCategoriesToUpdate.add(MangaCategory.create(manga, dbCategory)) - break - } - } - } - - // Update database - if (mangaCategoriesToUpdate.isNotEmpty()) { - databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking() - databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() - } - } - - /** - * Restore history from Json - * - * @param history list containing history to be restored - */ - internal fun restoreHistoryForManga(history: List) { - // List containing history to be updated - val historyToBeUpdated = ArrayList(history.size) - for ((url, lastRead) in history) { - val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking() - // Check if history already in database and update - if (dbHistory != null) { - dbHistory.apply { - last_read = max(lastRead, dbHistory.last_read) - } - historyToBeUpdated.add(dbHistory) - } else { - // If not in database create - databaseHelper.getChapter(url).executeAsBlocking()?.let { - val historyToAdd = History.create(it).apply { - last_read = lastRead - } - historyToBeUpdated.add(historyToAdd) - } - } - } - databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking() - } - - /** - * Restores the sync of a manga. - * - * @param manga the manga whose sync have to be restored. - * @param tracks the track list to restore. - */ - internal fun restoreTrackForManga(manga: Manga, tracks: List) { - // Get tracks from database - val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() - val trackToUpdate = ArrayList(tracks.size) - - tracks.forEach { track -> - // Fix foreign keys with the current manga id - track.manga_id = manga.id!! - - val service = trackManager.getService(track.sync_id) - if (service != null && service.isLogged) { - var isInDatabase = false - for (dbTrack in dbTracks) { - if (track.sync_id == dbTrack.sync_id) { - // The sync is already in the db, only update its fields - if (track.media_id != dbTrack.media_id) { - dbTrack.media_id = track.media_id - } - if (track.library_id != dbTrack.library_id) { - dbTrack.library_id = track.library_id - } - dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read) - isInDatabase = true - trackToUpdate.add(dbTrack) - break - } - } - if (!isInDatabase) { - // Insert new sync. Let the db assign the id - track.id = null - trackToUpdate.add(track) - } - } - } - // Update database - if (trackToUpdate.isNotEmpty()) { - databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() - } - } - - /** - * Restore the chapters for manga if chapters already in database - * - * @param manga manga of chapters - * @param chapters list containing chapters that get restored - * @return boolean answering if chapter fetch is not needed - */ - internal fun restoreChaptersForManga(manga: Manga, chapters: List): Boolean { - val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() - - // Return if fetch is needed - if (dbChapters.isEmpty() || dbChapters.size < chapters.size) { - return false - } - - for (chapter in chapters) { - val pos = dbChapters.indexOf(chapter) - if (pos != -1) { - val dbChapter = dbChapters[pos] - chapter.id = dbChapter.id - chapter.copyFrom(dbChapter) - break - } - - chapter.manga_id = manga.id - } - - // Filter the chapters that couldn't be found. - updateChapters(chapters.filter { it.id != null }) - - return true - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt deleted file mode 100644 index 4ce5be2541..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt +++ /dev/null @@ -1,184 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy - -import android.content.Context -import android.net.Uri -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.AbstractBackupRestore -import eu.kanade.tachiyomi.data.backup.BackupNotifier -import eu.kanade.tachiyomi.data.backup.legacy.models.Backup -import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory -import eu.kanade.tachiyomi.data.backup.legacy.models.MangaObject -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.source.Source -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonPrimitive -import java.util.Date - -class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore(context, notifier) { - - override suspend fun performRestore(uri: Uri): Boolean { - // Read the json and create a Json Object, - // cannot use the backupManager json deserializer one because its not initialized yet - val backupObject = Json.decodeFromStream( - context.contentResolver.openInputStream(uri)!!, - ) - - // Get parser version - val version = backupObject["version"]?.jsonPrimitive?.intOrNull ?: 1 - - // Initialize manager - backupManager = LegacyBackupManager(context, version) - - // Decode the json object to a Backup object - val backup = backupManager.parser.decodeFromJsonElement(backupObject) - - restoreAmount = backup.mangas.size + 1 // +1 for categories - - // Restore categories - backup.categories?.let { restoreCategories(it) } - - // Store source mapping for error messages - sourceMapping = LegacyBackupRestoreValidator.getSourceMapping(backup.extensions ?: emptyList()) - - // Restore individual manga - backup.mangas.forEach { - if (job?.isActive != true) { - return false - } - - restoreManga(it) - } - - return true - } - - private fun restoreCategories(categoriesJson: List) { - db.inTransaction { - backupManager.restoreCategories(categoriesJson) - } - - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories)) - } - - private suspend fun restoreManga(mangaJson: MangaObject) { - val manga = mangaJson.manga - val chapters = mangaJson.chapters ?: emptyList() - val categories = mangaJson.categories ?: emptyList() - val history = mangaJson.history ?: emptyList() - val tracks = mangaJson.track ?: emptyList() - - val source = backupManager.sourceManager.get(manga.source) - val sourceName = sourceMapping[manga.source] ?: manga.source.toString() - - try { - if (source != null) { - restoreMangaData(manga, source, chapters, categories, history, tracks) - } else { - errors.add(Date() to "${manga.title} [$sourceName]: ${context.getString(R.string.source_not_found_name, sourceName)}") - } - } catch (e: Exception) { - errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") - } - - restoreProgress += 1 - showRestoreProgress(restoreProgress, restoreAmount, manga.title) - } - - /** - * Returns a manga restore observable - * - * @param manga manga data from json - * @param source source to get manga data from - * @param chapters chapters data from json - * @param categories categories data from json - * @param history history data from json - * @param tracks tracking data from json - */ - private suspend fun restoreMangaData( - manga: Manga, - source: Source, - chapters: List, - categories: List, - history: List, - tracks: List, - ) { - val dbManga = backupManager.getMangaFromDatabase(manga) - - db.inTransaction { - if (dbManga == null) { - // Manga not in database - restoreMangaFetch(source, manga, chapters, categories, history, tracks) - } else { // Manga in database - // Copy information from manga already in database - backupManager.restoreMangaNoFetch(manga, dbManga) - // Fetch rest of manga information - restoreMangaNoFetch(source, manga, chapters, categories, history, tracks) - } - } - } - - /** - * Fetches manga information. - * - * @param manga manga that needs updating - * @param chapters chapters of manga that needs updating - * @param categories categories that need updating - */ - private suspend fun restoreMangaFetch( - source: Source, - manga: Manga, - chapters: List, - categories: List, - history: List, - tracks: List, - ) { - try { - val fetchedManga = backupManager.fetchManga(source, manga) - fetchedManga.id ?: return - - updateChapters(source, fetchedManga, chapters) - - restoreExtraForManga(fetchedManga, categories, history, tracks) - - updateTracking(fetchedManga, tracks) - } catch (e: Exception) { - errors.add(Date() to "${manga.title} - ${e.message}") - } - } - - private suspend fun restoreMangaNoFetch( - source: Source, - backupManga: Manga, - chapters: List, - categories: List, - history: List, - tracks: List, - ) { - if (!backupManager.restoreChaptersForManga(backupManga, chapters)) { - updateChapters(source, backupManga, chapters) - } - - restoreExtraForManga(backupManga, categories, history, tracks) - - updateTracking(backupManga, tracks) - } - - private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List) { - // Restore categories - backupManager.restoreCategoriesForManga(manga, categories) - - // Restore history - backupManager.restoreHistoryForManga(history) - - // Restore tracking - backupManager.restoreTrackForManga(manga, tracks) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt deleted file mode 100644 index 0f14108170..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt +++ /dev/null @@ -1,66 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy - -import android.content.Context -import android.net.Uri -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator -import eu.kanade.tachiyomi.data.backup.ValidatorParseException -import eu.kanade.tachiyomi.data.backup.legacy.models.Backup -import kotlinx.serialization.json.decodeFromStream - -class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() { - - /** - * Checks for critical backup file data. - * - * @throws Exception if version or manga cannot be found. - * @return List of missing sources or missing trackers. - */ - override fun validate(context: Context, uri: Uri): Results { - val backupManager = LegacyBackupManager(context) - - val backup = try { - backupManager.parser.decodeFromStream( - context.contentResolver.openInputStream(uri)!!, - ) - } catch (e: Exception) { - throw ValidatorParseException(e) - } - - if (backup.version == null) { - throw Exception(context.getString(R.string.invalid_backup_file_missing_data)) - } - - if (backup.mangas.isEmpty()) { - throw Exception(context.getString(R.string.invalid_backup_file_missing_manga)) - } - - val sources = getSourceMapping(backup.extensions ?: emptyList()) - val missingSources = sources - .filter { sourceManager.get(it.key) == null } - .values - .sorted() - - val trackers = backup.mangas - .filterNot { it.track.isNullOrEmpty() } - .flatMap { it.track ?: emptyList() } - .map { it.sync_id } - .distinct() - val missingTrackers = trackers - .mapNotNull { trackManager.getService(it) } - .filter { !it.isLogged } - .map { context.getString(it.nameRes()) } - .sorted() - - return Results(missingSources, missingTrackers) - } - - companion object { - fun getSourceMapping(extensionsMapping: List): Map { - return extensionsMapping.associate { - val items = it.split(":") - items[0].toLong() to items[1] - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt deleted file mode 100644 index ba965cfa32..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt +++ /dev/null @@ -1,37 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.models - -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.Track -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Serializable -data class Backup( - val version: Int? = null, - var mangas: MutableList = mutableListOf(), - var categories: List<@Contextual Category>? = null, - var extensions: List? = null, -) { - companion object { - const val CURRENT_VERSION = 2 - - fun getDefaultFilename(): String { - val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) - return "tachiyomi_$date.json" - } - } -} - -@Serializable -data class MangaObject( - var manga: @Contextual Manga, - var chapters: List<@Contextual Chapter>? = null, - var categories: List? = null, - var track: List<@Contextual Track>? = null, - var history: List<@Contextual DHistory>? = null, -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt deleted file mode 100644 index 9a0ea06609..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt +++ /dev/null @@ -1,3 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.models - -data class DHistory(val url: String, val lastRead: Long) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeSerializer.kt deleted file mode 100644 index ab6861bb23..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeSerializer.kt +++ /dev/null @@ -1,49 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.serializer - -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.CategoryImpl -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive - -/** - * JSON Serializer used to write / read [CategoryImpl] to / from json - */ -open class CategoryBaseSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Category") - - override fun serialize(encoder: Encoder, value: T) { - encoder as JsonEncoder - encoder.encodeJsonElement( - buildJsonArray { - add(value.name) - add(value.order) - }, - ) - } - - @Suppress("UNCHECKED_CAST") - override fun deserialize(decoder: Decoder): T { - // make a category impl and cast as T so that the serializer accepts it - return CategoryImpl().apply { - decoder as JsonDecoder - val array = decoder.decodeJsonElement().jsonArray - name = array[0].jsonPrimitive.content - order = array[1].jsonPrimitive.int - } as T - } -} - -// Allow for serialization of a category and category impl -object CategoryTypeSerializer : CategoryBaseSerializer() - -object CategoryImplTypeSerializer : CategoryBaseSerializer() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeSerializer.kt deleted file mode 100644 index a1ae4a6bf0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeSerializer.kt +++ /dev/null @@ -1,66 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.serializer - -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.ChapterImpl -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -/** - * JSON Serializer used to write / read [ChapterImpl] to / from json - */ -open class ChapterBaseSerializer : KSerializer { - - override val descriptor = buildClassSerialDescriptor("Chapter") - - override fun serialize(encoder: Encoder, value: T) { - encoder as JsonEncoder - encoder.encodeJsonElement( - buildJsonObject { - put(URL, value.url) - if (value.read) { - put(READ, 1) - } - if (value.bookmark) { - put(BOOKMARK, 1) - } - if (value.last_page_read != 0) { - put(LAST_READ, value.last_page_read) - } - }, - ) - } - - @Suppress("UNCHECKED_CAST") - override fun deserialize(decoder: Decoder): T { - // make a chapter impl and cast as T so that the serializer accepts it - return ChapterImpl().apply { - decoder as JsonDecoder - val jsonObject = decoder.decodeJsonElement().jsonObject - url = jsonObject[URL]!!.jsonPrimitive.content - read = jsonObject[READ]?.jsonPrimitive?.intOrNull == 1 - bookmark = jsonObject[BOOKMARK]?.jsonPrimitive?.intOrNull == 1 - last_page_read = jsonObject[LAST_READ]?.jsonPrimitive?.intOrNull ?: last_page_read - } as T - } - - companion object { - private const val URL = "u" - private const val READ = "r" - private const val BOOKMARK = "b" - private const val LAST_READ = "l" - } -} - -// Allow for serialization of a chapter and chapter impl -object ChapterTypeSerializer : ChapterBaseSerializer() - -object ChapterImplTypeSerializer : ChapterBaseSerializer() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeSerializer.kt deleted file mode 100644 index 7cbc7a4524..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeSerializer.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.serializer - -import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long - -/** - * JSON Serializer used to write / read [DHistory] to / from json - */ -object HistoryTypeSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("History") - - override fun serialize(encoder: Encoder, value: DHistory) { - encoder as JsonEncoder - encoder.encodeJsonElement( - buildJsonArray { - add(value.url) - add(value.lastRead) - }, - ) - } - - override fun deserialize(decoder: Decoder): DHistory { - decoder as JsonDecoder - val array = decoder.decodeJsonElement().jsonArray - return DHistory( - url = array[0].jsonPrimitive.content, - lastRead = array[1].jsonPrimitive.long, - ) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeSerializer.kt deleted file mode 100644 index bec833ab9f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeSerializer.kt +++ /dev/null @@ -1,56 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.serializer - -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaImpl -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long - -/** - * JSON Serializer used to write / read [MangaImpl] to / from json - */ -open class MangaBaseSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Manga") - - override fun serialize(encoder: Encoder, value: T) { - encoder as JsonEncoder - encoder.encodeJsonElement( - buildJsonArray { - add(value.url) - add(value.title) - add(value.source) - add(value.viewer_flags) - add(value.chapter_flags) - }, - ) - } - - @Suppress("UNCHECKED_CAST") - override fun deserialize(decoder: Decoder): T { - // make a manga impl and cast as T so that the serializer accepts it - return MangaImpl().apply { - decoder as JsonDecoder - val array = decoder.decodeJsonElement().jsonArray - url = array[0].jsonPrimitive.content - title = array[1].jsonPrimitive.content - source = array[2].jsonPrimitive.long - viewer_flags = array[3].jsonPrimitive.int - chapter_flags = array[4].jsonPrimitive.int - } as T - } -} - -// Allow for serialization of a manga and manga impl -object MangaTypeSerializer : MangaBaseSerializer() - -object MangaImplTypeSerializer : MangaBaseSerializer() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt deleted file mode 100644 index 4691e9a868..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt +++ /dev/null @@ -1,68 +0,0 @@ -package eu.kanade.tachiyomi.data.backup.legacy.serializer - -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.models.TrackImpl -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.float -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import kotlinx.serialization.json.put - -/** - * JSON Serializer used to write / read [TrackImpl] to / from json - */ -open class TrackBaseSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Track") - - override fun serialize(encoder: Encoder, value: T) { - encoder as JsonEncoder - encoder.encodeJsonElement( - buildJsonObject { - put(TITLE, value.title) - put(SYNC, value.sync_id) - put(MEDIA, value.media_id) - put(LIBRARY, value.library_id) - put(LAST_READ, value.last_chapter_read) - put(TRACKING_URL, value.tracking_url) - }, - ) - } - - @Suppress("UNCHECKED_CAST") - override fun deserialize(decoder: Decoder): T { - // make a track impl and cast as T so that the serializer accepts it - return TrackImpl().apply { - decoder as JsonDecoder - val jsonObject = decoder.decodeJsonElement().jsonObject - title = jsonObject[TITLE]!!.jsonPrimitive.content - sync_id = jsonObject[SYNC]!!.jsonPrimitive.int - media_id = jsonObject[MEDIA]!!.jsonPrimitive.int - library_id = jsonObject[LIBRARY]!!.jsonPrimitive.long - last_chapter_read = jsonObject[LAST_READ]!!.jsonPrimitive.float - tracking_url = jsonObject[TRACKING_URL]!!.jsonPrimitive.content - } as T - } - - companion object { - private const val SYNC = "s" - private const val MEDIA = "r" - private const val LIBRARY = "ml" - private const val TITLE = "t" - private const val LAST_READ = "l" - private const val TRACKING_URL = "u" - } -} - -// Allow for serialization of a track and track impl -object TrackTypeSerializer : TrackBaseSerializer() - -object TrackImplTypeSerializer : TrackBaseSerializer() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDatabaseHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDatabaseHelper.kt index 5ffd319d8b..ee61192399 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDatabaseHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDatabaseHelper.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.data.database -import android.content.Context import androidx.sqlite.db.SupportSQLiteOpenHelper import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite import eu.kanade.tachiyomi.data.database.mappers.AnimeCategoryTypeMapping @@ -25,8 +24,7 @@ import eu.kanade.tachiyomi.data.database.queries.EpisodeQueries /** * This class provides operations to manage the database through its interfaces. */ -open class AnimeDatabaseHelper( - context: Context, +class AnimeDatabaseHelper( openHelper: SupportSQLiteOpenHelper, ) : AnimeQueries, EpisodeQueries, AnimeTrackQueries, CategoryQueries, AnimeCategoryQueries, AnimeHistoryQueries { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDbOpenCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDbOpenCallback.kt index 198b96de1c..1fc7cf0ad6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDbOpenCallback.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/AnimeDbOpenCallback.kt @@ -26,7 +26,7 @@ class AnimeDbOpenCallback : SupportSQLiteOpenHelper.Callback(AnimeDatabase.Schem AnimeDatabase.Schema.migrate( driver = AndroidSqliteDriver(database = db, cacheSize = 1), oldVersion = oldVersion, - newVersion = newVersion + newVersion = newVersion, ) } } 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 1fbf55ad6f..ff38f1bcd7 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 @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.data.database -import android.content.Context import androidx.sqlite.db.SupportSQLiteOpenHelper import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite import eu.kanade.tachiyomi.data.database.mappers.CategoryTypeMapping @@ -25,8 +24,7 @@ import eu.kanade.tachiyomi.data.database.queries.TrackQueries /** * This class provides operations to manage the database through its interfaces. */ -open class DatabaseHelper( - context: Context, +class DatabaseHelper( openHelper: SupportSQLiteOpenHelper, ) : MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { 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 a27e4cbad8..d5cf594030 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 @@ -26,7 +26,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(Database.Schema.version) Database.Schema.migrate( driver = AndroidSqliteDriver(database = db, cacheSize = 1), oldVersion = oldVersion, - newVersion = newVersion + newVersion = newVersion, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeHistoryTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeHistoryTypeMapping.kt index 30fbf6753c..924d276b44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeHistoryTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeHistoryTypeMapping.kt @@ -14,7 +14,6 @@ import eu.kanade.tachiyomi.data.database.models.AnimeHistoryImpl import eu.kanade.tachiyomi.data.database.tables.AnimeHistoryTable.COL_EPISODE_ID import eu.kanade.tachiyomi.data.database.tables.AnimeHistoryTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.AnimeHistoryTable.COL_LAST_SEEN -import eu.kanade.tachiyomi.data.database.tables.AnimeHistoryTable.COL_TIME_SEEN import eu.kanade.tachiyomi.data.database.tables.AnimeHistoryTable.TABLE class AnimeHistoryTypeMapping : SQLiteTypeMapping( @@ -40,7 +39,6 @@ open class AnimeHistoryPutResolver : DefaultPutResolver() { COL_ID to obj.id, COL_EPISODE_ID to obj.episode_id, COL_LAST_SEEN to obj.last_seen, - COL_TIME_SEEN to obj.time_seen, ) } @@ -50,7 +48,6 @@ class AnimeHistoryGetResolver : DefaultGetResolver() { id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID)) episode_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_EPISODE_ID)) last_seen = cursor.getLong(cursor.getColumnIndexOrThrow(COL_LAST_SEEN)) - time_seen = cursor.getLong(cursor.getColumnIndexOrThrow(COL_TIME_SEEN)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeTrackTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeTrackTypeMapping.kt index 95426bb5c1..730c72dd1a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeTrackTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/AnimeTrackTypeMapping.kt @@ -68,7 +68,7 @@ class AnimeTrackGetResolver : DefaultGetResolver() { id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID)) anime_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ANIME_ID)) sync_id = cursor.getInt(cursor.getColumnIndexOrThrow(COL_SYNC_ID)) - media_id = cursor.getInt(cursor.getColumnIndexOrThrow(COL_MEDIA_ID)) + media_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_MEDIA_ID)) library_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_LIBRARY_ID)) title = cursor.getString(cursor.getColumnIndexOrThrow(COL_TITLE)) last_episode_seen = cursor.getFloat(cursor.getColumnIndexOrThrow(COL_LAST_EPISODE_SEEN)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt index 24ca7c26e8..764f2325af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt @@ -68,7 +68,7 @@ class TrackGetResolver : DefaultGetResolver() { id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID)) manga_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_MANGA_ID)) sync_id = cursor.getInt(cursor.getColumnIndexOrThrow(COL_SYNC_ID)) - media_id = cursor.getInt(cursor.getColumnIndexOrThrow(COL_MEDIA_ID)) + media_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_MEDIA_ID)) library_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_LIBRARY_ID)) title = cursor.getString(cursor.getColumnIndexOrThrow(COL_TITLE)) last_chapter_read = cursor.getFloat(cursor.getColumnIndexOrThrow(COL_LAST_CHAPTER_READ)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistory.kt index 236530fe22..97c5ad0e1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistory.kt @@ -22,11 +22,6 @@ interface AnimeHistory : Serializable { */ var last_seen: Long - /** - * Total time chapter was read - todo not yet implemented - */ - var time_seen: Long - companion object { /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistoryImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistoryImpl.kt index d1a2394f72..a4db28c576 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistoryImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeHistoryImpl.kt @@ -19,9 +19,4 @@ class AnimeHistoryImpl : AnimeHistory { * Last time chapter was read in time long format */ override var last_seen: Long = 0 - - /** - * Total time chapter was read - todo not yet implemented - */ - override var time_seen: Long = 0 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrack.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrack.kt index 4d393d2d6a..3abb1d0f70 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrack.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrack.kt @@ -10,7 +10,7 @@ interface AnimeTrack : Serializable { var sync_id: Int - var media_id: Int + var media_id: Long var library_id: Long? diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrackImpl.kt index 331b870402..e5928d8f31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/AnimeTrackImpl.kt @@ -8,7 +8,7 @@ class AnimeTrackImpl : AnimeTrack { override var sync_id: Int = 0 - override var media_id: Int = 0 + override var media_id: Long = 0 override var library_id: Long? = null @@ -30,19 +30,21 @@ class AnimeTrackImpl : AnimeTrack { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || javaClass != other.javaClass) return false + if (javaClass != other?.javaClass) return false - other as AnimeTrack + other as AnimeTrackImpl if (anime_id != other.anime_id) return false if (sync_id != other.sync_id) return false - return media_id == other.media_id + if (media_id != other.media_id) return false + + return true } override fun hashCode(): Int { - var result = (anime_id xor anime_id.ushr(32)).toInt() + var result = anime_id.hashCode() result = 31 * result + sync_id - result = 31 * result + media_id + result = 31 * result + media_id.hashCode() return result } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt index dff3bcb155..10429f8b1d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/History.kt @@ -23,7 +23,7 @@ interface History : Serializable { var last_read: Long /** - * Total time chapter was read - todo not yet implemented + * Total time chapter was read */ var time_read: Long diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/HistoryImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/HistoryImpl.kt index 94efcf2666..8b9dbe7662 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/HistoryImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/HistoryImpl.kt @@ -21,7 +21,7 @@ class HistoryImpl : History { override var last_read: Long = 0 /** - * Total time chapter was read - todo not yet implemented + * Total time chapter was read */ override var time_read: Long = 0 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt index b577451af5..4fb22b91a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt @@ -10,7 +10,7 @@ interface Track : Serializable { var sync_id: Int - var media_id: Int + var media_id: Long var library_id: Long? diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt index 082769b2d6..f3c0b90149 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt @@ -8,7 +8,7 @@ class TrackImpl : Track { override var sync_id: Int = 0 - override var media_id: Int = 0 + override var media_id: Long = 0 override var library_id: Long? = null @@ -30,19 +30,21 @@ class TrackImpl : Track { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || javaClass != other.javaClass) return false + if (javaClass != other?.javaClass) return false - other as Track + other as TrackImpl if (manga_id != other.manga_id) return false if (sync_id != other.sync_id) return false - return media_id == other.media_id + if (media_id != other.media_id) return false + + return true } override fun hashCode(): Int { - var result = (manga_id xor manga_id.ushr(32)).toInt() + var result = manga_id.hashCode() result = 31 * result + sync_id - result = 31 * result + media_id + result = 31 * result + media_id.hashCode() return result } } 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 86ee8d6045..9e54cc1d1d 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 @@ -30,16 +30,6 @@ interface HistoryQueries : DbProvider { ) .prepare() - /** - * Updates the history last read. - * Inserts history object if not yet in database - * @param history history object - */ - fun upsertHistoryLastRead(history: History) = db.put() - .`object`(history) - .withPutResolver(HistoryUpsertResolver()) - .prepare() - /** * Updates the history last read. * Inserts history object if not yet in database diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/AnimeHistoryTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/AnimeHistoryTable.kt index c5dadde7d8..d03f435b53 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/AnimeHistoryTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/AnimeHistoryTable.kt @@ -10,20 +10,15 @@ object AnimeHistoryTable { /** * Id column name */ - const val COL_ID = "${TABLE}_id" + const val COL_ID = "_id" /** * Episode id column name */ - const val COL_EPISODE_ID = "${TABLE}_episode_id" + const val COL_EPISODE_ID = "episode_id" /** * Last seen column name */ - const val COL_LAST_SEEN = "${TABLE}_last_seen" - - /** - * Time seen column name - */ - const val COL_TIME_SEEN = "${TABLE}_time_seen" + const val COL_LAST_SEEN = "last_seen" } 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 4dfe9f0ddc..e3eab5dee5 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 @@ -10,20 +10,20 @@ object HistoryTable { /** * Id column name */ - const val COL_ID = "${TABLE}_id" + const val COL_ID = "_id" /** * Chapter id column name */ - const val COL_CHAPTER_ID = "${TABLE}_chapter_id" + const val COL_CHAPTER_ID = "chapter_id" /** * Last read column name */ - const val COL_LAST_READ = "${TABLE}_last_read" + const val COL_LAST_READ = "last_read" /** * Time read column name */ - const val COL_TIME_READ = "${TABLE}_time_read" + const val COL_TIME_READ = "time_read" } 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 90c38d5375..05ee195f33 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 @@ -29,12 +29,4 @@ object TrackTable { const val COL_START_DATE = "start_date" const val COL_FINISH_DATE = "finish_date" - - val insertFromTempTable: String - get() = - """ - |INSERT INTO $TABLE($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) - |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() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/AnimeDownloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/AnimeDownloader.kt index 923093b310..f47e65c1e0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/AnimeDownloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/AnimeDownloader.kt @@ -670,17 +670,14 @@ class AnimeDownloader( val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") } download.status = if (downloadedImages.size == 1) { - AnimeDownload.State.DOWNLOADED - } else { - AnimeDownload.State.ERROR - } - - // Only rename the directory if it's downloaded. - if (download.status == AnimeDownload.State.DOWNLOADED) { + // Only rename the directory if it's downloaded. tmpDir.renameTo(dirname) cache.addEpisode(dirname, animeDir, download.anime) DiskUtil.createNoMediaFile(tmpDir, context) + AnimeDownload.State.DOWNLOADED + } else { + AnimeDownload.State.ERROR } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index b4535cfdec..f8ebc535da 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -1,8 +1,6 @@ package eu.kanade.tachiyomi.data.download import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.webkit.MimeTypeMap import com.hippo.unifile.UniFile import com.jakewharton.rxrelay.BehaviorRelay @@ -29,8 +27,6 @@ import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.system.ImageUtil -import eu.kanade.tachiyomi.util.system.ImageUtil.isAnimatedAndSupported -import eu.kanade.tachiyomi.util.system.ImageUtil.isTallImage import eu.kanade.tachiyomi.util.system.logcat import kotlinx.coroutines.async import logcat.LogPriority @@ -42,12 +38,9 @@ import rx.subscriptions.CompositeSubscription import uy.kohesive.injekt.injectLazy import java.io.BufferedOutputStream import java.io.File -import java.io.FileOutputStream import java.util.zip.CRC32 import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream -import kotlin.math.ceil -import kotlin.math.min /** * This class is the one in charge of downloading chapters. @@ -281,7 +274,7 @@ class Downloader( // Start downloader if needed if (autoStart && wasEmpty) { - val queuedDownloads = queue.filter { it.source !is UnmeteredSource }.count() + val queuedDownloads = queue.count { it.source !is UnmeteredSource } val maxDownloadsFromSource = queue .groupBy { it.source } .filterKeys { it !is UnmeteredSource } @@ -352,18 +345,14 @@ class Downloader( .flatMap({ page -> getOrDownloadImage(page, download, tmpDir) }, 5) .onBackpressureLatest() // Do when page is downloaded. - .doOnNext { page -> - if (preferences.splitTallImages().get()) { - splitTallImage(page, tmpDir) - } - notifier.onProgressChange(download) - } + .doOnNext { notifier.onProgressChange(download) } .toList() .map { download } // Do after download completes .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } // If the page list threw, it will resume here .onErrorReturn { error -> + logcat(LogPriority.ERROR, error) download.status = Download.State.ERROR notifier.onError(error.message, download.chapter.name, download.manga.title) download @@ -401,8 +390,12 @@ class Downloader( } return pageObservable - // When the image is ready, set image path, progress (just in case) and status + // When the page is ready, set page path, progress (just in case) and status .doOnNext { file -> + val success = splitTallImageIfNeeded(page, tmpDir) + if (success.not()) { + notifier.onError(context.getString(R.string.download_notifier_split_failed), download.chapter.name, download.manga.title) + } page.uri = file.uri page.progress = 100 download.downloadedImages++ @@ -487,6 +480,21 @@ class Downloader( return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg" } + private fun splitTallImageIfNeeded(page: Page, tmpDir: UniFile): Boolean { + if (!preferences.splitTallImages().get()) return true + + val filename = String.format("%03d", page.number) + val imageFile = tmpDir.listFiles()?.find { it.name!!.startsWith(filename) } + ?: throw Error(context.getString(R.string.download_notifier_split_page_not_found, page.number)) + val imageFilePath = imageFile.filePath + ?: throw Error(context.getString(R.string.download_notifier_split_page_path_not_found, page.number)) + + // check if the original page was previously splitted before then skip. + if (imageFile.name!!.contains("__")) return true + + return ImageUtil.splitTallImage(imageFile, imageFilePath) + } + /** * Checks if the download was successful. * @@ -505,13 +513,7 @@ class Downloader( val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") || (it.name!!.contains("__") && !it.name!!.contains("__001.jpg")) } download.status = if (downloadedImages.size == download.pages!!.size) { - Download.State.DOWNLOADED - } else { - Download.State.ERROR - } - - // Only rename the directory if it's downloaded. - if (download.status == Download.State.DOWNLOADED) { + // Only rename the directory if it's downloaded. if (preferences.saveChaptersAsCBZ().get()) { archiveChapter(mangaDir, dirname, tmpDir) } else { @@ -520,6 +522,10 @@ class Downloader( cache.addChapter(dirname, mangaDir, download.manga) DiskUtil.createNoMediaFile(tmpDir, context) + + Download.State.DOWNLOADED + } else { + Download.State.ERROR } } @@ -557,48 +563,6 @@ class Downloader( tmpDir.delete() } - /** - * Splits tall images to improve performance of reader - */ - private fun splitTallImage(page: Page, tmpDir: UniFile) { - val filename = String.format("%03d", page.number) - val imageFile = tmpDir.listFiles()?.find { it.name!!.startsWith("$filename.") } - ?: throw Error(context.getString(R.string.download_notifier_split_page_not_found, page.number)) - - if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) { - return - } - - val bitmap = BitmapFactory.decodeFile(imageFile.filePath) - val splitsCount = bitmap.height / context.resources.displayMetrics.heightPixels + 1 - val heightPerSplit = ceil(bitmap.height / splitsCount.toDouble()).toInt() - logcat { "Splitting height ${bitmap.height} by $splitsCount * $heightPerSplit" } - - try { - (0 until splitsCount).forEach { split -> - logcat { "Split #$split at y=${split * heightPerSplit}" } - val splitPath = imageFile.filePath!!.substringBeforeLast(".") + "__${"%03d".format(split + 1)}.jpg" - val splitHeight = split * heightPerSplit - FileOutputStream(splitPath).use { stream -> - Bitmap.createBitmap( - bitmap, - 0, - splitHeight, - bitmap.width, - min(heightPerSplit, bitmap.height - splitHeight), - ).compress(Bitmap.CompressFormat.JPEG, 100, stream) - } - } - imageFile.delete() - } catch (e: Exception) { - // Image splits were not successfully saved so delete them and keep the original image - (0 until splitsCount) - .map { imageFile.filePath!!.substringBeforeLast(".") + "__${"%03d".format(it + 1)}.jpg" } - .forEach { File(it).delete() } - throw e - } - } - /** * Completes a download. This method is called in the main thread. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index be2c51ab2f..fb14b3ce75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -10,6 +10,7 @@ import androidx.work.Worker import androidx.work.WorkerParameters import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING +import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.util.system.isConnectedToWifi @@ -22,8 +23,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet override fun doWork(): Result { val preferences = Injekt.get() - if (requiresWifiConnection(preferences) && !context.isConnectedToWifi()) { - Result.failure() + val restrictions = preferences.libraryUpdateDeviceRestriction().get() + if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { + return Result.failure() } return if (LibraryUpdateService.start(context)) { @@ -42,7 +44,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet if (interval > 0) { val restrictions = preferences.libraryUpdateDeviceRestriction().get() val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED }) .setRequiresCharging(DEVICE_CHARGING in restrictions) .setRequiresBatteryNotLow(DEVICE_BATTERY_NOT_LOW in restrictions) .build() @@ -62,10 +64,5 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet WorkManager.getInstance(context).cancelAllWorkByTag(TAG) } } - - fun requiresWifiConnection(preferences: PreferencesHelper): Boolean { - val restrictions = preferences.libraryUpdateDeviceRestriction().get() - return DEVICE_ONLY_ON_WIFI in restrictions - } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index ca14dd6895..8d6f735534 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -339,7 +339,7 @@ class LibraryUpdateNotifier(private val context: Context) { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP action = MainActivity.SHORTCUT_RECENTLY_UPDATED } - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } companion object { 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 f63f6a20f8..4350764fde 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 @@ -446,7 +446,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_RESUME_DOWNLOADS } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -459,7 +459,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_PAUSE_DOWNLOADS } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -472,7 +472,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CLEAR_DOWNLOADS } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -485,7 +485,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_RESUME_ANIME_DOWNLOADS } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -498,7 +498,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_PAUSE_ANIME_DOWNLOADS } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -511,7 +511,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CLEAR_ANIME_DOWNLOADS } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -526,7 +526,7 @@ class NotificationReceiver : BroadcastReceiver() { action = ACTION_DISMISS_NOTIFICATION putExtra(EXTRA_NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -581,7 +581,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_FILE_LOCATION, path) putExtra(EXTRA_NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -598,7 +598,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_FILE_LOCATION, path) putExtra(EXTRA_NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -610,7 +610,7 @@ class NotificationReceiver : BroadcastReceiver() { */ internal fun openEpisodePendingActivity(context: Context, anime: Anime, episode: Episode): PendingIntent { val newIntent = PlayerActivity.newIntent(context, anime, episode) - return PendingIntent.getActivity(context, AnimeController.REQUEST_INTERNAL, newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, AnimeController.REQUEST_INTERNAL, newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -626,7 +626,7 @@ class NotificationReceiver : BroadcastReceiver() { .putExtra(AnimeController.ANIME_EXTRA, anime.id) .putExtra("notificationId", anime.id.hashCode()) .putExtra("groupId", groupId) - return PendingIntent.getActivity(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -648,7 +648,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_NOTIFICATION_ID, anime.id.hashCode()) putExtra(EXTRA_GROUP_ID, groupId) } - return PendingIntent.getBroadcast(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -660,7 +660,7 @@ class NotificationReceiver : BroadcastReceiver() { */ internal fun openChapterPendingActivity(context: Context, manga: Manga, chapter: Chapter): PendingIntent { val newIntent = ReaderActivity.newIntent(context, manga, chapter) - return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -676,7 +676,7 @@ class NotificationReceiver : BroadcastReceiver() { .putExtra(MangaController.MANGA_EXTRA, manga.id) .putExtra("notificationId", manga.id.hashCode()) .putExtra("groupId", groupId) - return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -698,7 +698,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_NOTIFICATION_ID, manga.id.hashCode()) putExtra(EXTRA_GROUP_ID, groupId) } - return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -720,7 +720,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_NOTIFICATION_ID, manga.id.hashCode()) putExtra(EXTRA_GROUP_ID, groupId) } - return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -742,7 +742,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_NOTIFICATION_ID, anime.id.hashCode()) putExtra(EXTRA_GROUP_ID, groupId) } - return PendingIntent.getBroadcast(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -755,7 +755,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CANCEL_LIBRARY_UPDATE } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -768,7 +768,7 @@ class NotificationReceiver : BroadcastReceiver() { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CANCEL_ANIMELIB_UPDATE } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -782,7 +782,7 @@ class NotificationReceiver : BroadcastReceiver() { action = MainActivity.SHORTCUT_EXTENSIONS addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -799,7 +799,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_URI, uri) putExtra(EXTRA_NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -815,7 +815,7 @@ class NotificationReceiver : BroadcastReceiver() { setDataAndType(uri, "text/plain") flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } - return PendingIntent.getActivity(context, 0, intent, 0) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } /** @@ -832,7 +832,7 @@ class NotificationReceiver : BroadcastReceiver() { putExtra(EXTRA_URI, uri) putExtra(EXTRA_NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } /** @@ -847,7 +847,7 @@ class NotificationReceiver : BroadcastReceiver() { action = ACTION_CANCEL_RESTORE putExtra(EXTRA_NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt index 2fa35464b2..e669c63628 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceValues.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.preference import eu.kanade.tachiyomi.R const val DEVICE_ONLY_ON_WIFI = "wifi" +const val DEVICE_NETWORK_NOT_METERED = "network_not_metered" const val DEVICE_CHARGING = "ac" const val DEVICE_BATTERY_NOT_LOW = "battery_not_low" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt new file mode 100644 index 0000000000..9e4237718a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/AnimeTrackService.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.data.track + +interface AnimeTrackService diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt new file mode 100644 index 0000000000..07b183fd97 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/MangaTrackService.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.data.track + +interface MangaTrackService diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 05065920c2..584136bd22 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt @@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.komga.Komga +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList import eu.kanade.tachiyomi.data.track.shikimori.Shikimori @@ -17,6 +18,7 @@ class TrackManager(context: Context) { const val SHIKIMORI = 4 const val BANGUMI = 5 const val KOMGA = 6 + const val MANGA_UPDATES = 7 } val myAnimeList = MyAnimeList(context, MYANIMELIST) @@ -31,7 +33,9 @@ class TrackManager(context: Context) { val komga = Komga(context, KOMGA) - val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga) + val mangaUpdates = MangaUpdates(context, MANGA_UPDATES) + + val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates) fun getService(id: Int) = services.find { it.id == id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 5336774c00..2d24661d8f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -463,7 +463,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { private fun jsonToALManga(struct: JsonObject): ALManga { return ALManga( - struct["id"]!!.jsonPrimitive.int, + struct["id"]!!.jsonPrimitive.long, struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content, struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content, struct["description"]!!.jsonPrimitive.contentOrNull, @@ -476,7 +476,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { private fun jsonToALAnime(struct: JsonObject): ALAnime { return ALAnime( - struct["id"]!!.jsonPrimitive.int, + struct["id"]!!.jsonPrimitive.long, struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content, struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content, struct["description"]!!.jsonPrimitive.contentOrNull, @@ -550,11 +550,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { private const val baseMangaUrl = "https://anilist.co/manga/" private const val baseAnimeUrl = "https://anilist.co/anime/" - fun mangaUrl(mediaId: Int): String { + fun mangaUrl(mediaId: Long): String { return baseMangaUrl + mediaId } - fun animeUrl(mediaId: Int): String { + fun animeUrl(mediaId: Long): String { return baseAnimeUrl + mediaId } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt index 18ca114973..2b2cfe1b26 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt @@ -11,7 +11,7 @@ import java.text.SimpleDateFormat import java.util.Locale data class ALManga( - val media_id: Int, + val media_id: Long, val title_user_pref: String, val image_url_lge: String, val description: String?, @@ -42,7 +42,7 @@ data class ALManga( } data class ALAnime( - val media_id: Int, + val media_id: Long, val title_user_pref: String, val image_url_lge: String, val description: String?, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index 20b9d5267b..686802e403 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -20,6 +20,7 @@ import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import okhttp3.CacheControl import okhttp3.FormBody import okhttp3.OkHttpClient @@ -169,7 +170,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept 0 } return TrackSearch.create(TrackManager.BANGUMI).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["name_cn"]!!.jsonPrimitive.content cover_url = coverUrl summary = obj["name"]!!.jsonPrimitive.content @@ -191,7 +192,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept 0 } return AnimeTrackSearch.create(TrackManager.BANGUMI).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["name_cn"]!!.jsonPrimitive.content cover_url = coverUrl summary = obj["name"]!!.jsonPrimitive.content diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index c5e8df54dd..e50b62b71b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -13,10 +13,10 @@ import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.lang.withIOContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.FormBody @@ -72,7 +72,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) .await() .parseAs() .let { - track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int + track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long track } } @@ -117,7 +117,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) .await() .parseAs() .let { - track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int + track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long track } } @@ -410,11 +410,11 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) private const val algoliaFilterAnime = "&facetFilters=%5B%22kind%3Aanime%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22episodeCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" - fun mangaUrl(remoteId: Int): String { + fun mangaUrl(remoteId: Long): String { return baseMangaUrl + remoteId } - fun animeUrl(remoteId: Int): String { + fun animeUrl(remoteId: Long): String { return baseAnimeUrl + remoteId } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt index e65dc6dfa0..ab623b76a5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt @@ -12,12 +12,13 @@ import kotlinx.serialization.json.int import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import java.text.SimpleDateFormat import java.util.Date import java.util.Locale class KitsuSearchManga(obj: JsonObject) { - val id = obj["id"]!!.jsonPrimitive.int + val id = obj["id"]!!.jsonPrimitive.long private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull @@ -53,7 +54,7 @@ class KitsuSearchManga(obj: JsonObject) { } class KitsuSearchAnime(obj: JsonObject) { - val id = obj["id"]!!.jsonPrimitive.int + val id = obj["id"]!!.jsonPrimitive.long private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content private val episodeCount = obj["episodeCount"]?.jsonPrimitive?.intOrNull val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull @@ -98,7 +99,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) { private val startDate = manga["attributes"]!!.jsonObject["startDate"]?.jsonPrimitive?.contentOrNull.orEmpty() private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull - private val libraryId = obj["id"]!!.jsonPrimitive.int + private val libraryId = obj["id"]!!.jsonPrimitive.long val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int @@ -140,7 +141,7 @@ class KitsuLibAnime(obj: JsonObject, anime: JsonObject) { private val startDate = anime["attributes"]!!.jsonObject["startDate"]?.jsonPrimitive?.contentOrNull.orEmpty() private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull - private val libraryId = obj["id"]!!.jsonPrimitive.int + private val libraryId = obj["id"]!!.jsonPrimitive.long val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt index b72d8fafd3..a84c7b75f5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.database.models.AnimeTrack import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.EnhancedTrackService +import eu.kanade.tachiyomi.data.track.MangaTrackService import eu.kanade.tachiyomi.data.track.NoLoginTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch @@ -18,7 +19,7 @@ import eu.kanade.tachiyomi.source.Source import okhttp3.Dns import okhttp3.OkHttpClient -class Komga(private val context: Context, id: Int) : TrackService(id), EnhancedTrackService, NoLoginTrackService { +class Komga(private val context: Context, id: Int) : TrackService(id), EnhancedTrackService, NoLoginTrackService, MangaTrackService { companion object { const val UNREAD = 1 @@ -101,9 +102,7 @@ class Komga(private val context: Context, id: Int) : TrackService(id), EnhancedT return track } - override suspend fun refresh(track: AnimeTrack): AnimeTrack { - TODO("Not yet implemented") - } + override suspend fun refresh(track: AnimeTrack): AnimeTrack = throw Exception("Not used") override suspend fun login(username: String, password: String) { saveCredentials("user", "pass") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt new file mode 100644 index 0000000000..d29745cad4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt @@ -0,0 +1,130 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates + +import android.content.Context +import android.graphics.Color +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.MangaTrackService +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch +import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch +import eu.kanade.tachiyomi.data.track.model.TrackSearch + +class MangaUpdates(private val context: Context, id: Int) : TrackService(id), MangaTrackService { + + companion object { + const val READING_LIST = 0 + const val WISH_LIST = 1 + const val COMPLETE_LIST = 2 + const val UNFINISHED_LIST = 3 + const val ON_HOLD_LIST = 4 + } + + private val interceptor by lazy { MangaUpdatesInterceptor(this) } + + private val api by lazy { MangaUpdatesApi(interceptor, client) } + + @StringRes + override fun nameRes(): Int = R.string.tracker_manga_updates + + override fun getLogo(): Int = R.drawable.ic_manga_updates + + override fun getLogoColor(): Int = Color.rgb(146, 160, 173) + + override fun getStatusList(): List { + return listOf(READING_LIST, COMPLETE_LIST, ON_HOLD_LIST, UNFINISHED_LIST, WISH_LIST) + } + + override fun getStatusListAnime(): List = throw Exception("Not used") + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING_LIST -> getString(R.string.reading_list) + WISH_LIST -> getString(R.string.wish_list) + COMPLETE_LIST -> getString(R.string.complete_list) + ON_HOLD_LIST -> getString(R.string.on_hold_list) + UNFINISHED_LIST -> getString(R.string.unfinished_list) + else -> "" + } + } + + override fun getReadingStatus(): Int = READING_LIST + override fun getWatchingStatus(): Int = throw Exception("Not used") + + override fun getRereadingStatus(): Int = -1 + override fun getRewatchingStatus(): Int = throw Exception("Not used") + + override fun getCompletionStatus(): Int = COMPLETE_LIST + + private val _scoreList = (0..9).flatMap { i -> (0..9).map { j -> "$i.$j" } } + listOf("10.0") + + override fun getScoreList(): List = _scoreList + + override fun indexToScore(index: Int): Float = _scoreList[index].toFloat() + + override fun displayScore(track: Track): String = track.score.toString() + override fun displayScore(track: AnimeTrack): String { + TODO("Not yet implemented") + } + + override suspend fun update(track: Track, didReadChapter: Boolean): Track { + if (track.status != COMPLETE_LIST && didReadChapter) { + track.status = READING_LIST + } + api.updateSeriesListItem(track) + return track + } + + override suspend fun update(track: AnimeTrack, didWatchEpisode: Boolean): AnimeTrack { + TODO("Not yet implemented") + } + + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { + return try { + val (series, rating) = api.getSeriesListItem(track) + series.copyTo(track) + rating?.copyTo(track) ?: track + } catch (e: Exception) { + api.addSeriesToList(track, hasReadChapters) + track + } + } + + override suspend fun bind(track: AnimeTrack, hasReadChapters: Boolean): AnimeTrack { + TODO("Not yet implemented") + } + + override suspend fun search(query: String): List { + return api.search(query) + .map { + it.toTrackSearch(id) + } + } + + override suspend fun searchAnime(query: String): List { + TODO("Not yet implemented") + } + + override suspend fun refresh(track: Track): Track { + val (series, rating) = api.getSeriesListItem(track) + series.copyTo(track) + return rating?.copyTo(track) ?: track + } + + override suspend fun refresh(track: AnimeTrack): AnimeTrack { + TODO("Not yet implemented") + } + + override suspend fun login(username: String, password: String) { + val authenticated = api.authenticate(username, password) ?: throw Throwable("Unable to login") + saveCredentials(authenticated.uid.toString(), authenticated.sessionToken) + interceptor.newAuth(authenticated.sessionToken) + } + + fun restoreSession(): String? { + return preferences.trackPassword(this) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt new file mode 100644 index 0000000000..9e7404d08d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt @@ -0,0 +1,189 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_LIST +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Context +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Record +import eu.kanade.tachiyomi.network.DELETE +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.PUT +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.system.logcat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import logcat.LogPriority +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import uy.kohesive.injekt.injectLazy + +class MangaUpdatesApi( + interceptor: MangaUpdatesInterceptor, + private val client: OkHttpClient, +) { + private val json: Json by injectLazy() + + private val baseUrl = "https://api.mangaupdates.com" + private val contentType = "application/vnd.api+json".toMediaType() + + private val authClient by lazy { + client.newBuilder() + .addInterceptor(interceptor) + .build() + } + + suspend fun getSeriesListItem(track: Track): Pair { + val listItem = + authClient.newCall( + GET( + url = "$baseUrl/v1/lists/series/${track.media_id}", + ), + ) + .await() + .parseAs() + + val rating = getSeriesRating(track) + + return listItem to rating + } + + suspend fun addSeriesToList(track: Track, hasReadChapters: Boolean) { + val status = if (hasReadChapters) READING_LIST else WISH_LIST + val body = buildJsonArray { + addJsonObject { + putJsonObject("series") { + put("id", track.media_id) + } + put("list_id", status) + } + } + authClient.newCall( + POST( + url = "$baseUrl/v1/lists/series", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + .let { + if (it.code == 200) { + track.status = status + track.last_chapter_read = 1f + } + } + } + + suspend fun updateSeriesListItem(track: Track) { + val body = buildJsonArray { + addJsonObject { + putJsonObject("series") { + put("id", track.media_id) + } + put("list_id", track.status) + putJsonObject("status") { + put("chapter", track.last_chapter_read.toInt()) + } + } + } + authClient.newCall( + POST( + url = "$baseUrl/v1/lists/series/update", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + + updateSeriesRating(track) + } + + suspend fun getSeriesRating(track: Track): Rating? { + return try { + authClient.newCall( + GET( + url = "$baseUrl/v1/series/${track.media_id}/rating", + ), + ) + .await() + .parseAs() + } catch (e: Exception) { + null + } + } + + suspend fun updateSeriesRating(track: Track) { + if (track.score != 0f) { + val body = buildJsonObject { + put("rating", track.score) + } + authClient.newCall( + PUT( + url = "$baseUrl/v1/series/${track.media_id}/rating", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + } else { + authClient.newCall( + DELETE( + url = "$baseUrl/v1/series/${track.media_id}/rating", + ), + ) + .await() + } + } + + suspend fun search(query: String): List { + val body = buildJsonObject { + put("search", query) + } + return client.newCall( + POST( + url = "$baseUrl/v1/series/search", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + .parseAs() + .let { obj -> + obj["results"]?.jsonArray?.map { element -> + json.decodeFromJsonElement(element.jsonObject["record"]!!) + } + } + .orEmpty() + } + + suspend fun authenticate(username: String, password: String): Context? { + val body = buildJsonObject { + put("username", username) + put("password", password) + } + return client.newCall( + PUT( + url = "$baseUrl/v1/account/login", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + .parseAs() + .let { obj -> + try { + json.decodeFromJsonElement(obj["context"]!!) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + null + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt new file mode 100644 index 0000000000..2b283c3b83 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class MangaUpdatesInterceptor( + mangaUpdates: MangaUpdates, +) : Interceptor { + + private var token: String? = mangaUpdates.restoreSession() + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val token = token ?: throw IOException("Not authenticated with MangaUpdates") + + // Add the authorization header to the original request. + val authRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + + return chain.proceed(authRequest) + } + + fun newAuth(token: String?) { + this.token = token + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt new file mode 100644 index 0000000000..77019cacd2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Context( + @SerialName("session_token") + val sessionToken: String, + val uid: Long, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt new file mode 100644 index 0000000000..bed1f2657b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Image( + val url: Url? = null, + val height: Int? = null, + val width: Int? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt new file mode 100644 index 0000000000..4ed8bd7059 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ListItem( + val series: Series? = null, + @SerialName("list_id") + val listId: Int? = null, + val status: Status? = null, + val priority: Int? = null, +) + +fun ListItem.copyTo(track: Track): Track { + return track.apply { + this.status = listId ?: READING_LIST + this.last_chapter_read = this@copyTo.status?.chapter?.toFloat() ?: 0f + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt new file mode 100644 index 0000000000..b550a37f40 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import eu.kanade.tachiyomi.data.database.models.Track +import kotlinx.serialization.Serializable + +@Serializable +data class Rating( + val rating: Float? = null, +) + +fun Rating.copyTo(track: Track): Track { + return track.apply { + this.score = rating ?: 0f + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt new file mode 100644 index 0000000000..60dc5f0cb4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.util.lang.htmlDecode +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Record( + @SerialName("series_id") + val seriesId: Long? = null, + val title: String? = null, + val url: String? = null, + val description: String? = null, + val image: Image? = null, + val type: String? = null, + val year: String? = null, + @SerialName("bayesian_rating") + val bayesianRating: Double? = null, + @SerialName("rating_votes") + val ratingVotes: Int? = null, + @SerialName("latest_chapter") + val latestChapter: Int? = null, +) + +fun Record.toTrackSearch(id: Int): TrackSearch { + return TrackSearch.create(id).apply { + media_id = this@toTrackSearch.seriesId ?: 0L + title = this@toTrackSearch.title?.htmlDecode() ?: "" + total_chapters = 0 + cover_url = this@toTrackSearch.image?.url?.original ?: "" + summary = this@toTrackSearch.description?.htmlDecode() ?: "" + tracking_url = this@toTrackSearch.url ?: "" + publishing_status = "" + publishing_type = this@toTrackSearch.type.toString() + start_date = this@toTrackSearch.year.toString() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt new file mode 100644 index 0000000000..261c857372 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Series( + val id: Long? = null, + val title: String? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt new file mode 100644 index 0000000000..7320ac2e3d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Status( + val volume: Int? = null, + val chapter: Int? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt new file mode 100644 index 0000000000..f295d3bdc7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Url( + val original: String? = null, + val thumb: String? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/AnimeTrackSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/AnimeTrackSearch.kt index 40dab21415..5155129f3b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/AnimeTrackSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/AnimeTrackSearch.kt @@ -10,7 +10,7 @@ class AnimeTrackSearch : AnimeTrack { override var sync_id: Int = 0 - override var media_id: Int = 0 + override var media_id: Long = 0 override var library_id: Long? = null @@ -42,19 +42,21 @@ class AnimeTrackSearch : AnimeTrack { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || javaClass != other.javaClass) return false + if (javaClass != other?.javaClass) return false - other as AnimeTrack + other as AnimeTrackSearch if (anime_id != other.anime_id) return false if (sync_id != other.sync_id) return false - return media_id == other.media_id + if (media_id != other.media_id) return false + + return true } override fun hashCode(): Int { - var result = (anime_id xor anime_id.ushr(32)).toInt() + var result = anime_id.hashCode() result = 31 * result + sync_id - result = 31 * result + media_id + result = 31 * result + media_id.hashCode() return result } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt index 90c689b0d4..a043610d8b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt @@ -10,7 +10,7 @@ class TrackSearch : Track { override var sync_id: Int = 0 - override var media_id: Int = 0 + override var media_id: Long = 0 override var library_id: Long? = null @@ -42,19 +42,21 @@ class TrackSearch : Track { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || javaClass != other.javaClass) return false + if (javaClass != other?.javaClass) return false - other as Track + other as TrackSearch if (manga_id != other.manga_id) return false if (sync_id != other.sync_id) return false - return media_id == other.media_id + if (media_id != other.media_id) return false + + return true } override fun hashCode(): Int { - var result = (manga_id xor manga_id.ushr(32)).toInt() + var result = manga_id.hashCode() result = 31 * result + sync_id - result = 31 * result + media_id + result = 31 * result + media_id.hashCode() return result } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 6a068e6469..e0cb8a002d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -23,6 +23,7 @@ import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request @@ -119,7 +120,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI .let { val obj = it.jsonObject TrackSearch.create(TrackManager.MYANIMELIST).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["title"]!!.jsonPrimitive.content summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" total_chapters = obj["num_chapters"]!!.jsonPrimitive.int @@ -150,7 +151,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI .let { val obj = it.jsonObject AnimeTrackSearch.create(TrackManager.MYANIMELIST).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["title"]!!.jsonPrimitive.content summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" total_episodes = obj["num_episodes"]!!.jsonPrimitive.int @@ -393,12 +394,12 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI .appendQueryParameter("response_type", "code") .build() - fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon() + fun mangaUrl(id: Long): Uri = "$baseApiUrl/manga".toUri().buildUpon() .appendPath(id.toString()) .appendPath("my_list_status") .build() - fun animeUrl(id: Int): Uri = "$baseApiUrl/anime".toUri().buildUpon() + fun animeUrl(id: Long): Uri = "$baseApiUrl/anime".toUri().buildUpon() .appendPath(id.toString()) .appendPath("my_list_status") .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 2a5c3b9aad..e706cdc18c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -22,6 +22,7 @@ import kotlinx.serialization.json.float import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.FormBody @@ -118,7 +119,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter private fun jsonToSearch(obj: JsonObject): TrackSearch { return TrackSearch.create(TrackManager.SHIKIMORI).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["name"]!!.jsonPrimitive.content total_chapters = obj["chapters"]!!.jsonPrimitive.int cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content @@ -132,7 +133,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter private fun jsonToAnimeSearch(obj: JsonObject): AnimeTrackSearch { return AnimeTrackSearch.create(TrackManager.SHIKIMORI).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["name"]!!.jsonPrimitive.content total_episodes = obj["episodes"]!!.jsonPrimitive.int cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content @@ -147,7 +148,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track { return Track.create(TrackManager.SHIKIMORI).apply { title = mangas["name"]!!.jsonPrimitive.content - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long total_chapters = mangas["chapters"]!!.jsonPrimitive.int last_chapter_read = obj["chapters"]!!.jsonPrimitive.float score = obj["score"]!!.jsonPrimitive.int.toFloat() @@ -159,7 +160,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter private fun jsonToAnimeTrack(obj: JsonObject, animes: JsonObject): AnimeTrack { return AnimeTrack.create(TrackManager.SHIKIMORI).apply { title = animes["name"]!!.jsonPrimitive.content - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long total_episodes = animes["episodes"]!!.jsonPrimitive.int last_episode_seen = obj["episodes"]!!.jsonPrimitive.float score = obj["score"]!!.jsonPrimitive.int.toFloat() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt index 6218fd9dfe..7b22d3ba6c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt @@ -47,6 +47,7 @@ class AppUpdateChecker { when (result) { is AppUpdateResult.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release) is AppUpdateResult.NewUpdateFdroidInstallation -> AppUpdateNotifier(context).promptFdroidUpdate() + else -> {} } result @@ -64,7 +65,18 @@ class AppUpdateChecker { } else { // Release builds: based on releases in "jmir1/aniyomi" repo // tagged as something like "v0.1.2" - newVersion != BuildConfig.VERSION_NAME + val oldVersion = BuildConfig.VERSION_NAME.replace("[^\\d.]".toRegex(), "") + + val newSemVer = newVersion.split(".").map { it.toInt() } + val oldSemVer = oldVersion.split(".").map { it.toInt() } + + oldSemVer.mapIndexed { index, i -> + if (newSemVer[index] > i) { + return true + } + } + + false } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt index 289ea02d1c..c20c5a4fb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.updater +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -26,12 +27,13 @@ internal class AppUpdateNotifier(private val context: Context) { context.notificationManager.notify(id, build()) } + @SuppressLint("LaunchActivityFromNotification") fun promptUpdate(release: GithubRelease) { val intent = Intent(context, AppUpdateService::class.java).apply { putExtra(AppUpdateService.EXTRA_DOWNLOAD_URL, release.getDownloadLink()) putExtra(AppUpdateService.EXTRA_DOWNLOAD_TITLE, release.version) } - val updateIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + val updateIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt index 995025b2c6..b74d7bf888 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/AnimeExtensionGithubApi.kt @@ -10,7 +10,9 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.system.logcat import kotlinx.serialization.Serializable +import logcat.LogPriority import uy.kohesive.injekt.injectLazy import java.util.Date import java.util.concurrent.TimeUnit @@ -20,11 +22,24 @@ internal class AnimeExtensionGithubApi { private val networkService: NetworkHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy() + private var requiresFallbackSource = false + suspend fun findExtensions(): List { return withIOContext { - val extensions = networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .await() + val response = try { + networkService.client + .newCall(GET("${REPO_URL_PREFIX}index.min.json")) + .await() + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } + requiresFallbackSource = true + + networkService.client + .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) + .await() + } + + val extensions = response .parseAs>() .toExtensions() @@ -84,17 +99,26 @@ internal class AnimeExtensionGithubApi { hasReadme = false, hasChangelog = false, apkName = it.apk, - iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}", + iconUrl = "${getUrlPrefix()}icon/${it.apk.replace(".apk", ".png")}", ) } } fun getApkUrl(extension: AnimeExtension.Available): String { - return "${REPO_URL_PREFIX}apk/${extension.apkName}" + return "${getUrlPrefix()}apk/${extension.apkName}" + } + + private fun getUrlPrefix(): String { + return if (requiresFallbackSource) { + FALLBACK_REPO_URL_PREFIX + } else { + REPO_URL_PREFIX + } } } private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/jmir1/aniyomi-extensions/repo/" +private const val FALLBACK_REPO_URL_PREFIX = "https://fastly.jsdelivr.net/gh/jmir1/aniyomi-extensions@repo/" @Serializable data class AnimeExtensionJsonObject( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index 115ff57a46..92bae0dcb5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -11,7 +11,9 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.system.logcat import kotlinx.serialization.Serializable +import logcat.LogPriority import uy.kohesive.injekt.injectLazy import java.util.Date import java.util.concurrent.TimeUnit @@ -21,11 +23,24 @@ internal class ExtensionGithubApi { private val networkService: NetworkHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy() + private var requiresFallbackSource = false + suspend fun findExtensions(): List { return withIOContext { - val extensions = networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .await() + val response = try { + networkService.client + .newCall(GET("${REPO_URL_PREFIX}index.min.json")) + .await() + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } + requiresFallbackSource = true + + networkService.client + .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) + .await() + } + + val extensions = response .parseAs>() .toExtensions() @@ -85,7 +100,7 @@ internal class ExtensionGithubApi { hasChangelog = it.hasChangelog == 1, sources = it.sources?.toExtensionSources() ?: emptyList(), apkName = it.apk, - iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}", + iconUrl = "${getUrlPrefix()}icon/${it.apk.replace(".apk", ".png")}", ) } } @@ -101,11 +116,20 @@ internal class ExtensionGithubApi { } fun getApkUrl(extension: Extension.Available): String { - return "${REPO_URL_PREFIX}apk/${extension.apkName}" + return "${getUrlPrefix()}apk/${extension.apkName}" + } + + private fun getUrlPrefix(): String { + return if (requiresFallbackSource) { + FALLBACK_REPO_URL_PREFIX + } else { + REPO_URL_PREFIX + } } } private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" +private const val FALLBACK_REPO_URL_PREFIX = "https://fastly.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/" @Serializable private data class ExtensionJsonObject( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt index 2b2559d439..3d5aa306ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/AnimeExtension.kt @@ -56,3 +56,9 @@ sealed class AnimeExtension { override val hasChangelog: Boolean = false, ) : AnimeExtension() } + +data class AvailableAnimeExtensionSources( + val name: String, + val id: Long, + val baseUrl: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt index 11acd29697..ec8e8e26e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/AnimeExtensionInstallReceiver.kt @@ -52,6 +52,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : when (val result = getExtensionFromIntent(context, intent)) { is AnimeLoadResult.Success -> listener.onExtensionInstalled(result.extension) is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) + else -> {} } } } @@ -60,8 +61,8 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : when (val result = getExtensionFromIntent(context, intent)) { is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension) // Not needed as a package can't be upgraded if the signature is different - is AnimeLoadResult.Untrusted -> { - } + // is LoadResult.Untrusted -> {} + else -> {} } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt index cdef6e4368..2cbc71cbd6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt @@ -52,6 +52,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : when (val result = getExtensionFromIntent(context, intent)) { is LoadResult.Success -> listener.onExtensionInstalled(result.extension) is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) + else -> {} } } } @@ -60,8 +61,8 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : when (val result = getExtensionFromIntent(context, intent)) { is LoadResult.Success -> listener.onExtensionUpdated(result.extension) // Not needed as a package can't be upgraded if the signature is different - is LoadResult.Untrusted -> { - } + // is LoadResult.Untrusted -> {} + else -> {} } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index 8931b90b9e..8fb5ec2aa8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -36,3 +36,31 @@ fun POST( .cacheControl(cache) .build() } + +fun PUT( + url: String, + headers: Headers = DEFAULT_HEADERS, + body: RequestBody = DEFAULT_BODY, + cache: CacheControl = DEFAULT_CACHE_CONTROL, +): Request { + return Request.Builder() + .url(url) + .put(body) + .headers(headers) + .cacheControl(cache) + .build() +} + +fun DELETE( + url: String, + headers: Headers = DEFAULT_HEADERS, + body: RequestBody = DEFAULT_BODY, + cache: CacheControl = DEFAULT_CACHE_CONTROL, +): Request { + return Request.Builder() + .url(url) + .delete(body) + .headers(headers) + .cacheControl(cache) + .build() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 97f9facdad..36e4c86d42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -2,7 +2,9 @@ package eu.kanade.tachiyomi.source import android.content.Context import com.github.junrar.Archive +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -17,7 +19,6 @@ import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.system.ImageUtil -import eu.kanade.tachiyomi.util.system.logcat import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -26,10 +27,11 @@ import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive -import logcat.LogPriority import rx.Observable import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.MangaInfo +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.File import java.io.FileInputStream @@ -37,130 +39,104 @@ import java.io.InputStream import java.util.concurrent.TimeUnit import java.util.zip.ZipFile -class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource { +class LocalSource( + private val context: Context, + private val coverCache: CoverCache = Injekt.get(), +) : CatalogueSource, UnmeteredSource { - companion object { - const val ID = 0L - const val HELP_URL = "https://aniyomi.jmir.xyz/help/guides/local-manga/" - - private const val COVER_NAME = "cover.jpg" - private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) - - fun updateCover(context: Context, manga: SManga, input: InputStream): File? { - val dir = getBaseDirectories(context).firstOrNull() - if (dir == null) { - input.close() - return null - } - var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) - if (cover == null) { - cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) - } - // It might not exist if using the external SD card - cover.parentFile?.mkdirs() - input.use { - cover.outputStream().use { - input.copyTo(it) - } - } - manga.thumbnail_url = cover.absolutePath - return cover - } - - /** - * Returns valid cover file inside [parent] directory. - */ - private fun getCoverFile(parent: File): File? { - return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { - it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } - } - } + private val json: Json by injectLazy() - private fun getBaseDirectories(context: Context): List { - val c = context.getString(R.string.app_name) + File.separator + "local" - return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } - } - } + override val name: String = context.getString(R.string.local_manga_source) - private val json: Json by injectLazy() + override val id: Long = ID - override val id = ID - override val name = context.getString(R.string.local_manga_source) - override val lang = "other" - override val supportsLatest = true + override val lang: String = "other" override fun toString() = name + override val supportsLatest: Boolean = true + + // Browse related override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) + override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - val baseDirs = getBaseDirectories(context) + val baseDirsFiles = getBaseDirectoriesFiles(context) - val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L - var mangaDirs = baseDirs - .asSequence() - .mapNotNull { it.listFiles()?.toList() } - .flatten() - .filter { it.isDirectory } - .filterNot { it.name.startsWith('.') } - .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + var mangaDirs = baseDirsFiles + // Filter out files that are hidden and is not a folder + .filter { it.isDirectory && !it.name.startsWith('.') } .distinctBy { it.name } - val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state - when (state?.index) { - 0 -> { - mangaDirs = if (state.ascending) { - mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it.name })) - } else { - mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it.name })) - } + val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L + // Filter by query or last modified + mangaDirs = mangaDirs.filter { + if (lastModifiedLimit == 0L) { + it.name.contains(query, ignoreCase = true) + } else { + it.lastModified() >= lastModifiedLimit } - 1 -> { - mangaDirs = if (state.ascending) { - mangaDirs.sortedBy(File::lastModified) - } else { - mangaDirs.sortedByDescending(File::lastModified) + } + + filters.forEach { filter -> + when (filter) { + is OrderBy -> { + when (filter.state!!.index) { + 0 -> { + mangaDirs = if (filter.state!!.ascending) { + mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } else { + mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + } + 1 -> { + mangaDirs = if (filter.state!!.ascending) { + mangaDirs.sortedBy(File::lastModified) + } else { + mangaDirs.sortedByDescending(File::lastModified) + } + } + } } + + else -> { /* Do nothing */ } } } + // Transform mangaDirs to list of SManga val mangas = mangaDirs.map { mangaDir -> SManga.create().apply { title = mangaDir.name url = mangaDir.name // Try to find the cover - for (dir in baseDirs) { - val cover = getCoverFile(File("${dir.absolutePath}/$url")) - if (cover != null && cover.exists()) { - thumbnail_url = cover.absolutePath - break - } + val cover = getCoverFile(mangaDir.name, baseDirsFiles) + if (cover != null && cover.exists()) { + thumbnail_url = cover.absolutePath } + } + } - val sManga = this - val mangaInfo = this.toMangaInfo() - runBlocking { - val chapters = getChapterList(mangaInfo) - if (chapters.isNotEmpty()) { - val chapter = chapters.last().toSChapter() - val format = getFormat(chapter) - if (format is Format.Epub) { - EpubFile(format.file).use { epub -> - epub.fillMangaMetadata(sManga) - } - } + // Fetch chapters of all the manga + mangas.forEach { manga -> + val mangaInfo = manga.toMangaInfo() + runBlocking { + val chapters = getChapterList(mangaInfo) + if (chapters.isNotEmpty()) { + val chapter = chapters.last().toSChapter() + val format = getFormat(chapter) - // Copy the cover from the first chapter found. - if (thumbnail_url == null) { - try { - val dest = updateCover(chapter, sManga) - thumbnail_url = dest?.absolutePath - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - } + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillMangaMetadata(manga) } } + + // Copy the cover from the first chapter found if not available + if (manga.thumbnail_url == null) { + updateCover(chapter, manga) + } } } } @@ -168,38 +144,44 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour return Observable.just(MangasPage(mangas.toList(), false)) } - override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) - + // Manga details related override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo { - val localDetails = getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, manga.key).listFiles()?.toList() } - .flatten() + var mangaInfo = manga + + val baseDirsFile = getBaseDirectoriesFiles(context) + + val coverFile = getCoverFile(manga.key, baseDirsFile) + + coverFile?.let { + mangaInfo = mangaInfo.copy(cover = it.absolutePath) + } + + val localDetails = getMangaDirsFiles(manga.key, baseDirsFile) .firstOrNull { it.extension.equals("json", ignoreCase = true) } - return if (localDetails != null) { + if (localDetails != null) { val obj = json.decodeFromStream(localDetails.inputStream()) - manga.copy( - title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title, - author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author, - artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist, - description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description, - genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: manga.genres, - status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status, + mangaInfo = mangaInfo.copy( + title = obj["title"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.title, + author = obj["author"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.author, + artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.artist, + description = obj["description"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.description, + genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: mangaInfo.genres, + status = obj["status"]?.jsonPrimitive?.intOrNull ?: mangaInfo.status, ) - } else { - manga } + + return mangaInfo } + // Chapters override suspend fun getChapterList(manga: MangaInfo): List { val sManga = manga.toSManga() - val chapters = getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, manga.key).listFiles()?.toList() } - .flatten() + val baseDirsFile = getBaseDirectoriesFiles(context) + return getMangaDirsFiles(manga.key, baseDirsFile) + // Only keep supported formats .filter { it.isDirectory || isSupportedFile(it.extension) } .map { chapterFile -> SChapter.create().apply { @@ -211,14 +193,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } date_upload = chapterFile.lastModified() + ChapterRecognition.parseChapterNumber(this, sManga) + val format = getFormat(chapterFile) if (format is Format.Epub) { EpubFile(format.file).use { epub -> epub.fillChapterMetadata(this) } } - - ChapterRecognition.parseChapterNumber(this, sManga) } } .map { it.toChapterInfo() } @@ -227,12 +209,24 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c } .toList() - - return chapters } - override suspend fun getPageList(chapter: ChapterInfo) = throw Exception("Unused") + // Filters + override fun getFilterList() = FilterList(OrderBy(context)) + private val POPULAR_FILTERS = FilterList(OrderBy(context)) + private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) }) + + private class OrderBy(context: Context) : Filter.Sort( + context.getString(R.string.local_filter_order_by), + arrayOf(context.getString(R.string.title), context.getString(R.string.date)), + Selection(0, true), + ) + + // Unused stuff + override suspend fun getPageList(chapter: ChapterInfo) = throw UnsupportedOperationException("Unused") + + // Miscellaneous private fun isSupportedFile(extension: String): Boolean { return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES } @@ -296,25 +290,89 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } } } + .also { coverCache.clearMemoryCache() } } - override fun getFilterList() = POPULAR_FILTERS - - private val POPULAR_FILTERS = FilterList(OrderBy(context)) - private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) }) - - private class OrderBy(context: Context) : Filter.Sort( - context.getString(R.string.local_filter_order_by), - arrayOf(context.getString(R.string.title), context.getString(R.string.date)), - Selection(0, true), - ) - sealed class Format { data class Directory(val file: File) : Format() data class Zip(val file: File) : Format() data class Rar(val file: File) : Format() data class Epub(val file: File) : Format() } + + companion object { + const val ID = 0L + const val HELP_URL = "https://aniyomi.jmir.xyz/help/guides/local-manga/" + + private const val DEFAULT_COVER_NAME = "cover.jpg" + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) + + private fun getBaseDirectories(context: Context): Sequence { + val localFolder = context.getString(R.string.app_name) + File.separator + "local" + return DiskUtil.getExternalStorages(context) + .map { File(it.absolutePath, localFolder) } + .asSequence() + } + + private fun getBaseDirectoriesFiles(context: Context): Sequence { + return getBaseDirectories(context) + // Get all the files inside all baseDir + .flatMap { it.listFiles().orEmpty().toList() } + } + + private fun getMangaDir(mangaUrl: String, baseDirsFile: Sequence): File? { + return baseDirsFile + // Get the first mangaDir or null + .firstOrNull { it.isDirectory && it.name == mangaUrl } + } + + private fun getMangaDirsFiles(mangaUrl: String, baseDirsFile: Sequence): Sequence { + return baseDirsFile + // Filter out ones that are not related to the manga and is not a directory + .filter { it.isDirectory && it.name == mangaUrl } + // Get all the files inside the filtered folders + .flatMap { it.listFiles().orEmpty().toList() } + } + + private fun getCoverFile(mangaUrl: String, baseDirsFile: Sequence): File? { + return getMangaDirsFiles(mangaUrl, baseDirsFile) + // Get all file whose names start with 'cover' + .filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) } + // Get the first actual image + .firstOrNull { + ImageUtil.isImage(it.name) { it.inputStream() } + } + } + + fun updateCover(context: Context, manga: SManga, inputStream: InputStream): File? { + val baseDirsFiles = getBaseDirectoriesFiles(context) + + val mangaDir = getMangaDir(manga.url, baseDirsFiles) + if (mangaDir == null) { + inputStream.close() + return null + } + + var coverFile = getCoverFile(manga.url, baseDirsFiles) + if (coverFile == null) { + coverFile = File(mangaDir.absolutePath, DEFAULT_COVER_NAME) + } + + // It might not exist at this point + coverFile.parentFile?.mkdirs() + inputStream.use { input -> + coverFile.outputStream().use { output -> + input.copyTo(output) + } + } + + // Create a .nomedia file + DiskUtil.createNoMediaFile(UniFile.fromFile(mangaDir), context) + + manga.thumbnail_url = coverFile.absolutePath + return coverFile + } + } } private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub") diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 4e3495a2f6..87e1a71008 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -14,7 +14,7 @@ import rx.Observable import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.MangaInfo -open class SourceManager(private val context: Context) { +class SourceManager(private val context: Context) { private val sourcesMap = mutableMapOf() private val stubSourcesMap = mutableMapOf() @@ -28,7 +28,7 @@ open class SourceManager(private val context: Context) { createInternalSources().forEach { registerSource(it) } } - open fun get(sourceKey: Long): Source? { + fun get(sourceKey: Long): Source? { return sourcesMap[sourceKey] } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AddDuplicateAnimeDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AddDuplicateAnimeDialog.kt new file mode 100644 index 0000000000..6769b6ca36 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AddDuplicateAnimeDialog.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.ui.anime + +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.animesource.AnimeSourceManager +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.pushController +import uy.kohesive.injekt.injectLazy + +class AddDuplicateAnimeDialog(bundle: Bundle? = null) : DialogController(bundle) { + + private val sourceManager: AnimeSourceManager by injectLazy() + + private lateinit var libraryAnime: Anime + private lateinit var onAddToLibrary: () -> Unit + + constructor( + target: Controller, + libraryAnime: Anime, + onAddToLibrary: () -> Unit, + ) : this() { + targetController = target + + this.libraryAnime = libraryAnime + this.onAddToLibrary = onAddToLibrary + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val source = sourceManager.getOrStub(libraryAnime.source) + + return MaterialAlertDialogBuilder(activity!!) + .setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name)) + .setPositiveButton(activity?.getString(R.string.action_add)) { _, _ -> + onAddToLibrary() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(activity?.getString(R.string.action_show_anime)) { _, _ -> + dismissDialog() + router.pushController(AnimeController(libraryAnime)) + } + .setCancelable(true) + .create() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt index 6fd28f926c..9a94d8c04a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt @@ -31,7 +31,6 @@ import coil.request.ImageRequest import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import com.google.android.material.snackbar.Snackbar import dev.chrisbanes.insetter.applyInsetter @@ -56,6 +55,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.saver.Image import eu.kanade.tachiyomi.data.saver.Location import eu.kanade.tachiyomi.data.track.EnhancedTrackService +import eu.kanade.tachiyomi.data.track.MangaTrackService import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch @@ -562,38 +562,19 @@ class AnimeController : } else { val duplicateAnime = presenter.getDuplicateAnimelibAnime(anime) if (duplicateAnime != null) { - showAddDuplicateDialog( - anime, - duplicateAnime, - ) + AddDuplicateAnimeDialog(this, duplicateAnime) { addToAnimelib(anime) } + .showDialog(router) } else { addToAnimelib(anime) } } } - private fun showAddDuplicateDialog(newAnime: Anime, animelibAnime: Anime) { - activity?.let { - val source = sourceManager.getOrStub(animelibAnime.source) - MaterialAlertDialogBuilder(it).apply { - setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name)) - setPositiveButton(activity?.getString(R.string.action_add)) { _, _ -> - addToAnimelib(newAnime) - } - setNegativeButton(activity?.getString(R.string.action_cancel)) { _, _ -> } - setNeutralButton(activity?.getString(R.string.action_show_anime)) { _, _ -> - router.pushController(AnimeController(animelibAnime)) - } - setCancelable(true) - }.create().show() - } - } - fun onTrackingClick() { trackSheet?.show() } - private fun addToAnimelib(anime: Anime) { + private fun addToAnimelib(newAnime: Anime) { val categories = presenter.getCategories() val defaultCategoryId = preferences.defaultAnimeCategory() val defaultCategory = categories.find { it.id == defaultCategoryId } @@ -602,7 +583,7 @@ class AnimeController : // Default category set defaultCategory != null -> { toggleFavorite() - presenter.moveAnimeToCategory(anime, defaultCategory) + presenter.moveAnimeToCategory(newAnime, defaultCategory) activity?.toast(activity?.getString(R.string.manga_added_library)) activity?.invalidateOptionsMenu() } @@ -610,14 +591,14 @@ class AnimeController : // Automatic 'Default' or no categories defaultCategoryId == 0 || categories.isEmpty() -> { toggleFavorite() - presenter.moveAnimeToCategory(anime, null) + presenter.moveAnimeToCategory(newAnime, null) activity?.toast(activity?.getString(R.string.manga_added_library)) activity?.invalidateOptionsMenu() } // Choose a category else -> { - val ids = presenter.getAnimeCategoryIds(anime) + val ids = presenter.getAnimeCategoryIds(newAnime) val preselected = categories.map { if (it.id!! in ids) { QuadStateTextView.State.CHECKED.ordinal @@ -626,23 +607,24 @@ class AnimeController : } }.toIntArray() - showChangeCategoryDialog(anime, categories, preselected) + showChangeCategoryDialog(newAnime, categories, preselected) } } if (source != null) { presenter.trackList .map { it.service } + .filterNot { it is MangaTrackService } .filterIsInstance() .filter { it.accept(source!!) } .forEach { service -> launchIO { try { - service.match(anime)?.let { track -> + service.match(newAnime)?.let { track -> presenter.registerTracking(track, service as TrackService) } } catch (e: Exception) { - logcat(LogPriority.WARN, e) { "Could not match anime: ${anime.title} with service $service" } + logcat(LogPriority.WARN, e) { "Could not match anime: ${newAnime.title} with service $service" } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt index 4856c5d433..22c7b15003 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt @@ -29,6 +29,7 @@ class EpisodeHolder( } binding.animedownload.setOnLongClickListener { onAnimeDownloadLongClick(it, bindingAdapterPosition) + true } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt index 05509ee3d6..78b000d334 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt @@ -113,6 +113,7 @@ class EpisodesSettingsSheet( downloaded -> presenter.setDownloadedFilter(newState) unread -> presenter.setUnreadFilter(newState) bookmarked -> presenter.setBookmarkedFilter(newState) + else -> {} } initModels() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt index 5e4f36b38c..46128e18f1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt @@ -45,8 +45,8 @@ open class BaseEpisodeHolder( } } - fun onAnimeDownloadLongClick(view: View, position: Int): Boolean { - val item = adapter.getItem(position) as? BaseEpisodeItem<*, *> ?: return false + fun onAnimeDownloadLongClick(view: View, position: Int) { + val item = adapter.getItem(position) as? BaseEpisodeItem<*, *> ?: return when (item.status) { AnimeDownload.State.NOT_DOWNLOADED, AnimeDownload.State.ERROR -> { view.popupMenu( @@ -75,10 +75,13 @@ open class BaseEpisodeHolder( } }, ) - return true } + AnimeDownload.State.DOWNLOADED, AnimeDownload.State.DOWNLOADING -> { + adapter.clickListener.deleteEpisode(position) + } + // AnimeDownload.State.QUEUE else -> { - return false + adapter.clickListener.startDownloadNow(position) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibController.kt index d5c68f8b1e..f4bc938ade 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibController.kt @@ -8,7 +8,6 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode -import androidx.core.view.doOnAttach import androidx.core.view.isVisible import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType @@ -302,8 +301,10 @@ class AnimelibController( onTabsSettingsChanged(firstLaunch = true) // Delay the scroll position to allow the view to be properly measured. - view.doOnAttach { - (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) + view.post { + if (isAttached) { + (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) + } } // Send the anime map to child fragments after the adapter is updated. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibSettingsSheet.kt index 564cc3de25..0cbe90ef0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/animelib/AnimelibSettingsSheet.kt @@ -394,6 +394,7 @@ class AnimelibSettingsSheet( unseenBadge -> preferences.unreadBadge().set(item.checked) localBadge -> preferences.localBadge().set(item.checked) languageBadge -> preferences.languageBadge().set(item.checked) + else -> {} } adapter.notifyItemChanged(item) } @@ -418,6 +419,7 @@ class AnimelibSettingsSheet( when (item) { showTabs -> preferences.animeCategoryTabs().set(item.checked) showNumberOfItems -> preferences.animeCategoryNumberOfItems().set(item.checked) + else -> {} } adapter.notifyItemChanged(item) } 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 index 44f32c2446..431b906ae4 100644 --- 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 @@ -30,7 +30,7 @@ abstract class ComposeController

>(bundle: Bundle? = null) : consumeWindowInsets = false setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val nestedScrollInterop = rememberNestedScrollInteropConnection(binding.root) + val nestedScrollInterop = rememberNestedScrollInteropConnection() TachiyomiTheme { ComposeContent(nestedScrollInterop) } @@ -56,7 +56,7 @@ abstract class BasicComposeController : consumeWindowInsets = false setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val nestedScrollInterop = rememberNestedScrollInteropConnection(binding.root) + val nestedScrollInterop = rememberNestedScrollInteropConnection() TachiyomiTheme { ComposeContent(nestedScrollInterop) } @@ -79,7 +79,7 @@ abstract class SearchableComposeController

>(bundle: Bundle? consumeWindowInsets = false setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val nestedScrollInterop = rememberNestedScrollInteropConnection(binding.root) + val nestedScrollInterop = rememberNestedScrollInteropConnection() TachiyomiTheme { ComposeContent(nestedScrollInterop) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt index 847f97214b..63f63e1d00 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt @@ -97,7 +97,7 @@ abstract class DialogController : Controller { /** * Dismiss the dialog and pop this controller */ - private fun dismissDialog() { + fun dismissDialog() { if (dismissed) { return } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt index 0dafdd2a5e..79e16f635e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt @@ -19,12 +19,12 @@ import eu.kanade.tachiyomi.databinding.PagerControllerBinding import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RxController import eu.kanade.tachiyomi.ui.base.controller.TabbedController -import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionController -import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourceController -import eu.kanade.tachiyomi.ui.browse.extension.ExtensionController +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionsController +import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesController +import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsController import eu.kanade.tachiyomi.ui.browse.migration.animesources.MigrationAnimeSourcesController import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesController -import eu.kanade.tachiyomi.ui.browse.source.SourceController +import eu.kanade.tachiyomi.ui.browse.source.SourcesController import eu.kanade.tachiyomi.ui.main.MainActivity import uy.kohesive.injekt.injectLazy @@ -150,10 +150,10 @@ class BrowseController : override fun configureRouter(router: Router, position: Int) { if (!router.hasRootController()) { val controller: Controller = when (position) { - SOURCES_CONTROLLER -> SourceController() - ANIMESOURCES_CONTROLLER -> AnimeSourceController() - EXTENSIONS_CONTROLLER -> ExtensionController() - ANIMEEXTENSIONS_CONTROLLER -> AnimeExtensionController() + SOURCES_CONTROLLER -> SourcesController() + ANIMESOURCES_CONTROLLER -> AnimeSourcesController() + EXTENSIONS_CONTROLLER -> ExtensionsController() + ANIMEEXTENSIONS_CONTROLLER -> AnimeExtensionsController() MIGRATION_CONTROLLER_ANIME -> MigrationAnimeSourcesController() MIGRATION_CONTROLLER -> MigrationSourcesController() else -> error("Wrong position $position") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionAdapter.kt deleted file mode 100644 index fc8782fcea..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible - -/** - * Adapter that holds the catalogue cards. - * - * @param controller instance of [AnimeExtensionController]. - */ -class AnimeExtensionAdapter(controller: AnimeExtensionController) : - FlexibleAdapter>(null, controller, true) { - - init { - setDisplayHeadersAtStartUp(true) - } - - /** - * Listener for browse item clicks. - */ - val buttonClickListener: OnButtonClickListener = controller - - interface OnButtonClickListener { - fun onButtonClick(position: Int) - fun onCancelButtonClick(position: Int) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionController.kt deleted file mode 100644 index e49ff75ca8..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionController.kt +++ /dev/null @@ -1,229 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -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.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import dev.chrisbanes.insetter.applyInsetter -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource -import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.BrowseController -import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionDetailsController -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.appcompat.queryTextChanges -import reactivecircus.flowbinding.swiperefreshlayout.refreshes - -/** - * Controller to manage the catalogues available in the app. - */ -open class AnimeExtensionController : - NucleusController(), - AnimeExtensionAdapter.OnButtonClickListener, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - AnimeExtensionTrustDialog.Listener { - - /** - * Adapter containing the list of manga from the catalogue. - */ - private var adapter: FlexibleAdapter>? = null - - private var extensions: List = emptyList() - - private var query = "" - - init { - setHasOptionsMenu(true) - } - - override fun getTitle(): String? { - return applicationContext?.getString(R.string.label_animeextensions) - } - - override fun createPresenter(): AnimeExtensionPresenter { - return AnimeExtensionPresenter(activity!!) - } - - override fun createBinding(inflater: LayoutInflater) = - ExtensionControllerBinding.inflate(inflater) - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - binding.recycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } - - binding.swipeRefresh.isRefreshing = true - binding.swipeRefresh.refreshes() - .onEach { presenter.findAvailableExtensions() } - .launchIn(viewScope) - - // Initialize adapter, scroll listener and recycler views - adapter = AnimeExtensionAdapter(this) - // Create recycler and set adapter. - binding.recycler.layoutManager = LinearLayoutManager(view.context) - binding.recycler.adapter = adapter - adapter?.fastScroller = binding.fastScroller - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> expandActionViewFromInteraction = true - R.id.action_settings -> { - parentController!!.router.pushController(AnimeExtensionFilterController()) - } - } - return super.onOptionsItemSelected(item) - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - if (type.isPush) { - presenter.findAvailableExtensions() - } - } - - override fun onButtonClick(position: Int) { - val extension = (adapter?.getItem(position) as? AnimeExtensionItem)?.extension ?: return - when (extension) { - is AnimeExtension.Available -> presenter.installExtension(extension) - is AnimeExtension.Untrusted -> openTrustDialog(extension) - is AnimeExtension.Installed -> { - if (!extension.hasUpdate) { - openDetails(extension) - } else { - presenter.updateExtension(extension) - } - } - } - } - - override fun onCancelButtonClick(position: Int) { - val extension = (adapter?.getItem(position) as? AnimeExtensionItem)?.extension ?: return - presenter.cancelInstallUpdateExtension(extension) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.browse_extensions, menu) - - val searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.maxWidth = Int.MAX_VALUE - - // Fixes problem with the overflow icon showing up in lieu of search - searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) - - if (query.isNotEmpty()) { - searchItem.expandActionView() - searchView.setQuery(query, true) - searchView.clearFocus() - } - - searchView.queryTextChanges() - .drop(1) // Drop first event after subscribed - .filter { router.backstack.lastOrNull()?.controller == this } - .onEach { - query = it.toString() - updateExtensionsList() - } - .launchIn(viewScope) - } - - override fun onItemClick(view: View, position: Int): Boolean { - val extension = (adapter?.getItem(position) as? AnimeExtensionItem)?.extension ?: return false - when (extension) { - is AnimeExtension.Available -> presenter.installExtension(extension) - is AnimeExtension.Untrusted -> openTrustDialog(extension) - is AnimeExtension.Installed -> openDetails(extension) - } - return false - } - - override fun onItemLongClick(position: Int) { - val extension = (adapter?.getItem(position) as? AnimeExtensionItem)?.extension ?: return - if (extension is AnimeExtension.Installed || extension is AnimeExtension.Untrusted) { - uninstallExtension(extension.pkgName) - } - } - - private fun openDetails(extension: AnimeExtension.Installed) { - val controller = AnimeExtensionDetailsController(extension.pkgName) - parentController!!.router.pushController(controller) - } - - private fun openTrustDialog(extension: AnimeExtension.Untrusted) { - AnimeExtensionTrustDialog(this, extension.signatureHash, extension.pkgName) - .showDialog(router) - } - - fun setExtensions(extensions: List) { - binding.swipeRefresh.isRefreshing = false - this.extensions = extensions - updateExtensionsList() - - // Update badge on parent controller tab - val ctrl = parentController as BrowseController - ctrl.setAnimeExtensionUpdateBadge() - ctrl.extensionListUpdateRelay.call(true) - } - - private fun updateExtensionsList() { - if (query.isNotBlank()) { - val queries = query.split(",") - adapter?.updateDataSet( - extensions.filter { - queries.any { query -> - when (it.extension) { - is AnimeExtension.Installed -> { - it.extension.sources.any { - it.name.contains(query, ignoreCase = true) || - it.id == query.toLongOrNull() || - if (it is AnimeHttpSource) { it.baseUrl.contains(query, ignoreCase = true) } else false - } || it.extension.name.contains(query, ignoreCase = true) - } - is AnimeExtension.Untrusted, is AnimeExtension.Available -> { - it.extension.name.contains(query, ignoreCase = true) - } - } - } - }, - ) - } else { - adapter?.updateDataSet(extensions) - } - } - - fun downloadUpdate(item: AnimeExtensionItem) { - adapter?.updateItem(item, item.installStep) - } - - override fun trustSignature(signatureHash: String) { - presenter.trustSignature(signatureHash) - } - - override fun uninstallExtension(pkgName: String) { - presenter.uninstallExtension(pkgName) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterController.kt index 19a09c120e..eab7d7671e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterController.kt @@ -1,45 +1,27 @@ package eu.kanade.tachiyomi.ui.browse.animeextension -import androidx.preference.PreferenceScreen +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import eu.kanade.presentation.browse.AnimeExtensionFilterScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.extension.AnimeExtensionManager -import eu.kanade.tachiyomi.ui.setting.SettingsController -import eu.kanade.tachiyomi.util.preference.minusAssign -import eu.kanade.tachiyomi.util.preference.onChange -import eu.kanade.tachiyomi.util.preference.plusAssign -import eu.kanade.tachiyomi.util.preference.switchPreference -import eu.kanade.tachiyomi.util.preference.titleRes -import eu.kanade.tachiyomi.util.system.LocaleHelper -import uy.kohesive.injekt.injectLazy +import eu.kanade.tachiyomi.ui.base.controller.ComposeController -class AnimeExtensionFilterController : SettingsController() { +class AnimeExtensionFilterController : ComposeController() { - private val extensionManager: AnimeExtensionManager by injectLazy() + override fun getTitle() = resources?.getString(R.string.label_animeextensions) - override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { - titleRes = R.string.label_extensions + override fun createPresenter(): AnimeExtensionFilterPresenter = AnimeExtensionFilterPresenter() - val activeLangs = preferences.enabledLanguages().get() - - val availableLangs = extensionManager.availableExtensions.groupBy { it.lang }.keys - .sortedWith(compareBy({ it !in activeLangs }, { LocaleHelper.getSourceDisplayName(it, context) })) - - availableLangs.forEach { - switchPreference { - preferenceScreen.addPreference(this) - title = LocaleHelper.getSourceDisplayName(it, context) - isPersistent = false - isChecked = it in activeLangs - - onChange { newValue -> - if (newValue as Boolean) { - preferences.enabledLanguages() += it - } else { - preferences.enabledLanguages() -= it - } - true - } - } - } + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + AnimeExtensionFilterScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onClickLang = { language -> + presenter.toggleLanguage(language) + }, + ) } } + +data class FilterUiModel(val lang: String, val enabled: Boolean) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterPresenter.kt new file mode 100644 index 0000000000..6d36764dda --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionFilterPresenter.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.browse.animeextension + +import android.os.Bundle +import eu.kanade.domain.extension.interactor.GetExtensionLanguages +import eu.kanade.domain.source.interactor.ToggleLanguage +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeExtensionFilterPresenter( + private val getExtensionLanguages: GetExtensionLanguages = Injekt.get(), + private val toggleLanguage: ToggleLanguage = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get(), +) : BasePresenter() { + + private val _state: MutableStateFlow = MutableStateFlow(ExtensionFilterState.Loading) + val state: StateFlow = _state.asStateFlow() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + presenterScope.launchIO { + getExtensionLanguages.subscribe() + .catch { exception -> + _state.value = ExtensionFilterState.Error(exception) + } + .collectLatest(::collectLatestSourceLangMap) + } + } + + private fun collectLatestSourceLangMap(extLangs: List) { + val enabledLanguages = preferences.enabledLanguages().get() + val uiModels = extLangs.map { + FilterUiModel(it, it in enabledLanguages) + } + _state.value = ExtensionFilterState.Success(uiModels) + } + + fun toggleLanguage(language: String) { + toggleLanguage.await(language) + } +} + +sealed class ExtensionFilterState { + object Loading : ExtensionFilterState() + data class Error(val error: Throwable) : ExtensionFilterState() + data class Success(val models: List) : ExtensionFilterState() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionGroupHolder.kt deleted file mode 100644 index 67b692cd06..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionGroupHolder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import android.annotation.SuppressLint -import android.view.View -import androidx.core.view.isVisible -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding - -class AnimeExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) : - FlexibleViewHolder(view, adapter) { - - private val binding = SectionHeaderItemBinding.bind(view) - - @SuppressLint("SetTextI18n") - fun bind(item: AnimeExtensionGroupItem) { - var text = item.name - if (item.showSize) { - text += " (${item.size})" - } - binding.title.text = text - - binding.actionButton.isVisible = item.actionLabel != null && item.actionOnClick != null - binding.actionButton.text = item.actionLabel - binding.actionButton.setOnClickListener(if (item.actionLabel != null) item.actionOnClick else null) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionGroupItem.kt deleted file mode 100644 index c8d0168474..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionGroupItem.kt +++ /dev/null @@ -1,62 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractHeaderItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R - -/** - * Item that contains the group header. - * - * @param name The header name. - * @param size The number of items in the group. - */ -data class AnimeExtensionGroupItem( - val name: String, - val size: Int, - val showSize: Boolean = false, -) : AbstractHeaderItem() { - - var actionLabel: String? = null - var actionOnClick: (View.OnClickListener)? = null - - /** - * Returns the layout resource of this item. - */ - override fun getLayoutRes(): Int { - return R.layout.section_header_item - } - - /** - * Creates a new view holder for this item. - */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): AnimeExtensionGroupHolder { - return AnimeExtensionGroupHolder(view, adapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: AnimeExtensionGroupHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other is AnimeExtensionGroupItem) { - return name == other.name - } - return false - } - - override fun hashCode(): Int { - return name.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionHolder.kt deleted file mode 100644 index 07798823e4..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionHolder.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import android.view.View -import androidx.core.view.isVisible -import coil.dispose -import coil.load -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.ExtensionItemBinding -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.util.system.LocaleHelper - -class AnimeExtensionHolder(view: View, val adapter: AnimeExtensionAdapter) : - FlexibleViewHolder(view, adapter) { - - private val binding = ExtensionItemBinding.bind(view) - - init { - binding.extButton.setOnClickListener { - adapter.buttonClickListener.onButtonClick(bindingAdapterPosition) - } - binding.cancelButton.setOnClickListener { - adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition) - } - } - - fun bind(item: AnimeExtensionItem) { - val extension = item.extension - - binding.name.text = extension.name - binding.version.text = extension.versionName - binding.lang.text = LocaleHelper.getSourceDisplayName(extension.lang, itemView.context) - binding.warning.text = when { - extension is AnimeExtension.Untrusted -> itemView.context.getString(R.string.ext_untrusted) - extension is AnimeExtension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial) - extension is AnimeExtension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete) - extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short) - else -> "" - }.uppercase() - - binding.icon.dispose() - if (extension is AnimeExtension.Available) { - binding.icon.load(extension.iconUrl) - } else if (extension is AnimeExtension.Installed) { - binding.icon.load(extension.icon) - } - bindButtons(item) - } - - @Suppress("ResourceType") - fun bindButtons(item: AnimeExtensionItem) = with(binding.extButton) { - val extension = item.extension - - val installStep = item.installStep - setText( - when (installStep) { - InstallStep.Pending -> R.string.ext_pending - InstallStep.Downloading -> R.string.ext_downloading - InstallStep.Installing -> R.string.ext_installing - InstallStep.Installed -> R.string.ext_installed - InstallStep.Error -> R.string.action_retry - InstallStep.Idle -> { - when (extension) { - is AnimeExtension.Installed -> { - if (extension.hasUpdate) { - R.string.ext_update - } else { - R.string.action_settings - } - } - is AnimeExtension.Untrusted -> R.string.ext_trust - is AnimeExtension.Available -> R.string.ext_install - } - } - }, - ) - - val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error - binding.cancelButton.isVisible = !isIdle - isEnabled = isIdle - isClickable = isIdle - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionItem.kt deleted file mode 100644 index 70adcaac40..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionItem.kt +++ /dev/null @@ -1,65 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -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.animesource.AnimeCatalogueSource -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.InstallStep - -/** - * Item that contains source information. - * - * @param source Instance of [AnimeCatalogueSource] containing source information. - * @param header The header for this item. - */ -data class AnimeExtensionItem( - val extension: AnimeExtension, - val header: AnimeExtensionGroupItem? = null, - val installStep: InstallStep = InstallStep.Idle, -) : - AbstractSectionableItem(header) { - - /** - * Returns the layout resource of this item. - */ - override fun getLayoutRes(): Int { - return R.layout.extension_item - } - - /** - * Creates a new view holder for this item. - */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): AnimeExtensionHolder { - return AnimeExtensionHolder(view, adapter as AnimeExtensionAdapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: AnimeExtensionHolder, - position: Int, - payloads: List?, - ) { - if (payloads == null || payloads.isEmpty()) { - holder.bind(this) - } else { - holder.bindButtons(this) - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return extension.pkgName == (other as AnimeExtensionItem).extension.pkgName - } - - override fun hashCode(): Int { - return extension.pkgName.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionPresenter.kt deleted file mode 100644 index 2229d4692b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionPresenter.kt +++ /dev/null @@ -1,180 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import android.content.Context -import android.os.Bundle -import android.view.View -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferenceValues -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.AnimeExtensionManager -import eu.kanade.tachiyomi.extension.model.AnimeExtension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.system.LocaleHelper -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit - -private typealias AnimeExtensionTuple = - Triple, List, List> - -/** - * Presenter of [AnimeExtensionController]. - */ -open class AnimeExtensionPresenter( - private val context: Context, - private val extensionManager: AnimeExtensionManager = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get(), -) : BasePresenter() { - - private var extensions = emptyList() - - private var currentDownloads = hashMapOf() - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - extensionManager.findAvailableExtensions() - bindToExtensionsObservable() - } - - private fun bindToExtensionsObservable(): Subscription { - val installedObservable = extensionManager.getInstalledExtensionsObservable() - val untrustedObservable = extensionManager.getUntrustedExtensionsObservable() - val availableObservable = extensionManager.getAvailableExtensionsObservable() - .startWith(emptyList()) - - return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) } - .debounce(500, TimeUnit.MILLISECONDS) - .map(::toItems) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) }) - } - - @Synchronized - private fun toItems(tuple: AnimeExtensionTuple): List { - val activeLangs = preferences.enabledLanguages().get() - val showNsfwSources = preferences.showNsfwSource().get() - - val (installed, untrusted, available) = tuple - - val items = mutableListOf() - - val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) } - .sortedWith( - compareBy { !it.isObsolete } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, - ) - - val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - val availableSorted = available - // Filter out already installed extensions and disabled languages - .filter { avail -> - installed.none { it.pkgName == avail.pkgName } && - untrusted.none { it.pkgName == avail.pkgName } && - avail.lang in activeLangs && - (showNsfwSources || !avail.isNsfw) - } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - if (updatesSorted.isNotEmpty()) { - val header = AnimeExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true) - if (preferences.extensionInstaller().get() != PreferenceValues.ExtensionInstaller.LEGACY) { - header.actionLabel = context.getString(R.string.ext_update_all) - header.actionOnClick = View.OnClickListener { _ -> - extensions - .filter { it.extension is AnimeExtension.Installed && it.extension.hasUpdate } - .forEach { updateExtension(it.extension as AnimeExtension.Installed) } - } - } - items += updatesSorted.map { extension -> - AnimeExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) - } - } - if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) { - val header = AnimeExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size) - - items += installedSorted.map { extension -> - AnimeExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) - } - - items += untrustedSorted.map { extension -> - AnimeExtensionItem(extension, header) - } - } - if (availableSorted.isNotEmpty()) { - val availableGroupedByLang = availableSorted - .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } - .toSortedMap() - - availableGroupedByLang - .forEach { - val header = AnimeExtensionGroupItem(it.key, it.value.size) - items += it.value.map { extension -> - AnimeExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) - } - } - } - - this.extensions = items - return items - } - - @Synchronized - private fun updateInstallStep(extension: AnimeExtension, state: InstallStep): AnimeExtensionItem? { - val extensions = extensions.toMutableList() - val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } - - return if (position != -1) { - val item = extensions[position].copy(installStep = state) - extensions[position] = item - - this.extensions = extensions - item - } else { - null - } - } - - fun installExtension(extension: AnimeExtension.Available) { - extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) - } - - fun updateExtension(extension: AnimeExtension.Installed) { - extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) - } - - fun cancelInstallUpdateExtension(extension: AnimeExtension) { - extensionManager.cancelInstallUpdateExtension(extension) - } - - private fun Observable.subscribeToInstallUpdate(extension: AnimeExtension) { - this.doOnNext { currentDownloads[extension.pkgName] = it } - .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } - .map { state -> updateInstallStep(extension, state) } - .subscribeWithView({ view, item -> - if (item != null) { - view.downloadUpdate(item) - } - },) - } - - fun uninstallExtension(pkgName: String) { - extensionManager.uninstallExtension(pkgName) - } - - fun findAvailableExtensions() { - extensionManager.findAvailableExtensions() - } - - fun trustSignature(signatureHash: String) { - extensionManager.trustSignature(signatureHash) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionTrustDialog.kt deleted file mode 100644 index 7b1707b5dd..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionTrustDialog.kt +++ /dev/null @@ -1,43 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import android.app.Dialog -import android.os.Bundle -import androidx.core.os.bundleOf -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class AnimeExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : AnimeExtensionTrustDialog.Listener { - - constructor(target: T, signatureHash: String, pkgName: String) : this( - bundleOf( - SIGNATURE_KEY to signatureHash, - PKGNAME_KEY to pkgName, - ), - ) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.untrusted_extension) - .setMessage(R.string.untrusted_extension_message) - .setPositiveButton(R.string.ext_trust) { _, _ -> - (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!) - } - .setNegativeButton(R.string.ext_uninstall) { _, _ -> - (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!) - } - .create() - } - - interface Listener { - fun trustSignature(signatureHash: String) - fun uninstallExtension(pkgName: String) - } -} - -private const val SIGNATURE_KEY = "signature_key" -private const val PKGNAME_KEY = "pkgname_key" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionViewUtils.kt deleted file mode 100644 index ff8397bd69..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionViewUtils.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension - -import android.content.Context -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable -import eu.kanade.tachiyomi.extension.model.AnimeExtension - -fun AnimeExtension.getApplicationIcon(context: Context): Drawable? { - return try { - context.packageManager.getApplicationIcon(pkgName) - } catch (e: PackageManager.NameNotFoundException) { - null - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionsController.kt new file mode 100644 index 0000000000..a218ba9420 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionsController.kt @@ -0,0 +1,120 @@ +package eu.kanade.tachiyomi.ui.browse.animeextension + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import eu.kanade.presentation.browse.AnimeExtensionScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.ui.base.controller.ComposeController +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.browse.BrowseController +import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.queryTextChanges + +class AnimeExtensionsController : ComposeController() { + + private var query = "" + + init { + setHasOptionsMenu(true) + } + + override fun getTitle() = applicationContext?.getString(R.string.label_extensions) + + override fun createPresenter() = AnimeExtensionsPresenter() + + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + AnimeExtensionScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onLongClickItem = { extension -> + when (extension) { + is AnimeExtension.Available -> presenter.installExtension(extension) + else -> presenter.uninstallExtension(extension.pkgName) + } + }, + onClickItemCancel = { extension -> + presenter.cancelInstallUpdateExtension(extension) + }, + onClickUpdateAll = { + presenter.updateAllExtensions() + }, + onLaunched = { + val ctrl = parentController as BrowseController + ctrl.setExtensionUpdateBadge() + ctrl.extensionListUpdateRelay.call(true) + }, + onInstallExtension = { + presenter.installExtension(it) + }, + onOpenExtension = { + val controller = ExtensionDetailsController(it.pkgName) + parentController!!.router.pushController(controller) + }, + onTrustExtension = { + presenter.trustSignature(it.signatureHash) + }, + onUninstallExtension = { + presenter.uninstallExtension(it.pkgName) + }, + onUpdateExtension = { + presenter.updateExtension(it) + }, + onRefresh = { + presenter.findAvailableExtensions() + }, + ) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_search -> expandActionViewFromInteraction = true + R.id.action_settings -> { + parentController!!.router.pushController(AnimeExtensionFilterController()) + } + } + return super.onOptionsItemSelected(item) + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isPush) { + presenter.findAvailableExtensions() + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.browse_extensions, menu) + + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + // Fixes problem with the overflow icon showing up in lieu of search + searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) + + if (query.isNotEmpty()) { + searchItem.expandActionView() + searchView.setQuery(query, true) + searchView.clearFocus() + } + + searchView.queryTextChanges() + .filter { router.backstack.lastOrNull()?.controller == this } + .onEach { + query = it.toString() + presenter.search(query) + } + .launchIn(viewScope) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionsPresenter.kt new file mode 100644 index 0000000000..859947442d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/AnimeExtensionsPresenter.kt @@ -0,0 +1,221 @@ +package eu.kanade.tachiyomi.ui.browse.animeextension + +import android.app.Application +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionUpdates +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensions +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.extension.AnimeExtensionManager +import eu.kanade.tachiyomi.extension.model.AnimeExtension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeExtensionsPresenter( + private val extensionManager: AnimeExtensionManager = Injekt.get(), + private val getExtensionUpdates: GetAnimeExtensionUpdates = Injekt.get(), + private val getExtensions: GetAnimeExtensions = Injekt.get(), +) : BasePresenter() { + + private val _query: MutableStateFlow = MutableStateFlow("") + + private var _currentDownloads = MutableStateFlow>(hashMapOf()) + + private val _state: MutableStateFlow = MutableStateFlow(ExtensionState.Uninitialized) + val state: StateFlow = _state.asStateFlow() + + var isRefreshing: Boolean by mutableStateOf(true) + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + extensionManager.findAvailableExtensions() + + val context = Injekt.get() + val extensionMapper: (Map) -> ((AnimeExtension) -> ExtensionUiModel) = { map -> + { + ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle) + } + } + val queryFilter: (String) -> ((AnimeExtension) -> Boolean) = { query -> + filter@{ extension -> + if (query.isEmpty()) return@filter true + query.split(",").any { _input -> + val input = _input.trim() + if (input.isEmpty()) return@any false + when (extension) { + is AnimeExtension.Available -> { + extension.name.contains(input, ignoreCase = true) || + extension.pkgName.contains(input, ignoreCase = true) + } + is AnimeExtension.Installed -> { + extension.sources.any { + it.name.contains(input, ignoreCase = true) || + it.id == input.toLongOrNull() || + if (it is AnimeHttpSource) { it.baseUrl.contains(input, ignoreCase = true) } else false + } || extension.name.contains(input, ignoreCase = true) + } + is AnimeExtension.Untrusted -> extension.name.contains(input, ignoreCase = true) + } + } + } + } + + launchIO { + combine( + _query, + getExtensions.subscribe(), + getExtensionUpdates.subscribe(), + _currentDownloads, + ) { query, (installed, untrusted, available), updates, downloads -> + isRefreshing = false + + val languagesWithExtensions = available + .filter(queryFilter(query)) + .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } + .toSortedMap() + .flatMap { (key, value) -> + listOf( + ExtensionUiModel.Header.Text(key), + *value.map(extensionMapper(downloads)).toTypedArray(), + ) + } + + val items = mutableListOf() + + val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads)) + if (updates.isNotEmpty()) { + items.add(ExtensionUiModel.Header.Resource(R.string.ext_updates_pending)) + items.addAll(updates) + } + + val installed = installed.filter(queryFilter(query)).map(extensionMapper(downloads)) + val untrusted = untrusted.filter(queryFilter(query)).map(extensionMapper(downloads)) + if (installed.isNotEmpty() || untrusted.isNotEmpty()) { + items.add(ExtensionUiModel.Header.Resource(R.string.ext_installed)) + items.addAll(installed) + items.addAll(untrusted) + } + + if (languagesWithExtensions.isNotEmpty()) { + items.addAll(languagesWithExtensions) + } + + items + }.collectLatest { + _state.value = ExtensionState.Initialized(it) + } + } + } + + fun search(query: String) { + launchIO { + _query.emit(query) + } + } + + fun updateAllExtensions() { + launchIO { + val state = _state.value + if (state !is ExtensionState.Initialized) return@launchIO + state.list.mapNotNull { + if (it !is ExtensionUiModel.Item) return@mapNotNull null + if (it.extension !is AnimeExtension.Installed) return@mapNotNull null + if (it.extension.hasUpdate.not()) return@mapNotNull null + it.extension + }.forEach { + updateExtension(it) + } + } + } + + fun installExtension(extension: AnimeExtension.Available) { + extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) + } + + fun updateExtension(extension: AnimeExtension.Installed) { + extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) + } + + fun cancelInstallUpdateExtension(extension: AnimeExtension) { + extensionManager.cancelInstallUpdateExtension(extension) + } + + private fun removeDownloadState(extension: AnimeExtension) { + _currentDownloads.update { map -> + val map = map.toMutableMap() + map.remove(extension.pkgName) + map + } + } + + private fun addDownloadState(extension: AnimeExtension, installStep: InstallStep) { + _currentDownloads.update { map -> + val map = map.toMutableMap() + map[extension.pkgName] = installStep + map + } + } + + private fun Observable.subscribeToInstallUpdate(extension: AnimeExtension) { + this + .doOnUnsubscribe { removeDownloadState(extension) } + .subscribe( + { installStep -> addDownloadState(extension, installStep) }, + { removeDownloadState(extension) }, + ) + } + + fun uninstallExtension(pkgName: String) { + extensionManager.uninstallExtension(pkgName) + } + + fun findAvailableExtensions() { + isRefreshing = true + extensionManager.findAvailableExtensions() + } + + fun trustSignature(signatureHash: String) { + extensionManager.trustSignature(signatureHash) + } +} + +sealed interface ExtensionUiModel { + sealed interface Header : ExtensionUiModel { + data class Resource(@StringRes val textRes: Int) : Header + data class Text(val text: String) : Header + } + data class Item( + val extension: AnimeExtension, + val installStep: InstallStep, + ) : ExtensionUiModel { + + fun key(): String { + return when (extension) { + is AnimeExtension.Installed -> + if (extension.hasUpdate) "update_${extension.pkgName}" else extension.pkgName + else -> extension.pkgName + } + } + } +} + +sealed class ExtensionState { + object Uninitialized : ExtensionState() + data class Initialized(val list: List) : ExtensionState() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsController.kt index f2d5941133..c29faa2237 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsController.kt @@ -1,59 +1,30 @@ package eu.kanade.tachiyomi.ui.browse.animeextension.details import android.annotation.SuppressLint -import android.content.Context import android.os.Bundle -import android.util.TypedValue -import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.View -import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.core.os.bundleOf -import androidx.preference.PreferenceGroupAdapter -import androidx.preference.PreferenceManager -import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreferenceCompat -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.LinearLayoutManager -import dev.chrisbanes.insetter.applyInsetter +import eu.kanade.presentation.browse.AnimeExtensionDetailsScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource -import eu.kanade.tachiyomi.animesource.AnimeSource -import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource -import eu.kanade.tachiyomi.animesource.getPreferenceKey import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource -import eu.kanade.tachiyomi.data.preference.EmptyPreferenceDataStore -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.databinding.ExtensionDetailControllerBinding -import eu.kanade.tachiyomi.extension.model.AnimeExtension import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.openInBrowser import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.util.preference.DSL -import eu.kanade.tachiyomi.util.preference.minusAssign -import eu.kanade.tachiyomi.util.preference.onChange -import eu.kanade.tachiyomi.util.preference.plusAssign -import eu.kanade.tachiyomi.util.preference.switchPreference -import eu.kanade.tachiyomi.util.preference.switchSettingsPreference -import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.logcat -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import okhttp3.HttpUrl.Companion.toHttpUrl import uy.kohesive.injekt.injectLazy @SuppressLint("RestrictedApi") class AnimeExtensionDetailsController(bundle: Bundle? = null) : - NucleusController(bundle) { + ComposeController(bundle) { - private val preferences: PreferencesHelper by injectLazy() private val network: NetworkHelper by injectLazy() - private var preferenceScreen: PreferenceScreen? = null - constructor(pkgName: String) : this( bundleOf(PKGNAME_KEY to pkgName), ) @@ -62,122 +33,22 @@ class AnimeExtensionDetailsController(bundle: Bundle? = null) : setHasOptionsMenu(true) } - override fun createBinding(inflater: LayoutInflater): ExtensionDetailControllerBinding { - val themedInflater = inflater.cloneInContext(getPreferenceThemeContext()) - return ExtensionDetailControllerBinding.inflate(themedInflater) - } - - override fun createPresenter(): AnimeExtensionDetailsPresenter { - return AnimeExtensionDetailsPresenter(this, args.getString(PKGNAME_KEY)!!) - } - - override fun getTitle(): String? { - return resources?.getString(R.string.label_extension_info) - } - - @SuppressLint("PrivateResource") - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - binding.extensionPrefsRecycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } + override fun getTitle() = resources?.getString(R.string.label_extension_info) - val extension = presenter.extension ?: return - val context = view.context + override fun createPresenter() = AnimeExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!) - binding.extensionPrefsRecycler.layoutManager = LinearLayoutManager(context) - binding.extensionPrefsRecycler.adapter = ConcatAdapter( - AnimeExtensionDetailsHeaderAdapter(presenter), - initPreferencesAdapter(context, extension), + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + AnimeExtensionDetailsScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onClickUninstall = { presenter.uninstallExtension() }, + onClickAppInfo = { presenter.openInSettings() }, + onClickSourcePreferences = { router.pushController(AnimeSourcePreferencesController(it)) }, + onClickSource = { presenter.toggleSource(it) }, ) } - private fun initPreferencesAdapter(context: Context, extension: AnimeExtension.Installed): PreferenceGroupAdapter { - val themedContext = getPreferenceThemeContext() - val manager = PreferenceManager(themedContext) - manager.preferenceDataStore = EmptyPreferenceDataStore() - val screen = manager.createPreferenceScreen(themedContext) - preferenceScreen = screen - - val isMultiSource = extension.sources.size > 1 - val isMultiLangSingleSource = isMultiSource && extension.sources.map { it.name }.distinct().size == 1 - - with(screen) { - if (isMultiSource && isMultiLangSingleSource.not()) { - multiLanguagePreference(context, extension.sources) - } else { - singleLanguagePreference(context, extension.sources) - } - } - - return PreferenceGroupAdapter(screen) - } - - private fun PreferenceScreen.singleLanguagePreference(context: Context, sources: List) { - sources - .map { source -> LocaleHelper.getSourceDisplayName(source.lang, context) to source } - .sortedWith(compareBy({ (_, source) -> !source.isEnabled() }, { (lang, _) -> lang.lowercase() })) - .forEach { (lang, source) -> - sourceSwitchPreference(source, lang) - } - } - - private fun PreferenceScreen.multiLanguagePreference(context: Context, sources: List) { - sources - .groupBy { (it as AnimeCatalogueSource).lang } - .toSortedMap(compareBy { LocaleHelper.getSourceDisplayName(it, context) }) - .forEach { entry -> - entry.value - .sortedWith(compareBy({ source -> !source.isEnabled() }, { source -> source.name.lowercase() })) - .forEach { source -> - sourceSwitchPreference(source, source.toString()) - } - } - } - - private fun PreferenceScreen.sourceSwitchPreference(source: AnimeSource, name: String) { - val block: (@DSL SwitchPreferenceCompat).() -> Unit = { - key = source.getPreferenceKey() - title = name - isPersistent = false - isChecked = source.isEnabled() - - onChange { newValue -> - val checked = newValue as Boolean - toggleSource(source, checked) - true - } - - // React to enable/disable all changes - preferences.disabledAnimeSources().asFlow() - .onEach { - val enabled = source.isEnabled() - isChecked = enabled - } - .launchIn(viewScope) - } - - // Source enable/disable - if (source is ConfigurableAnimeSource) { - switchSettingsPreference { - block() - onSettingsClick = View.OnClickListener { - router.pushController(AnimeSourcePreferencesController(source.id)) - } - } - } else { - switchPreference(block) - } - } - - override fun onDestroyView(view: View) { - preferenceScreen = null - super.onDestroyView(view) - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.extension_details, menu) @@ -203,15 +74,7 @@ class AnimeExtensionDetailsController(bundle: Bundle? = null) : } private fun toggleAllSources(enable: Boolean) { - presenter.extension?.sources?.forEach { toggleSource(it, enable) } - } - - private fun toggleSource(source: AnimeSource, enable: Boolean) { - if (enable) { - preferences.disabledAnimeSources() -= source.id.toString() - } else { - preferences.disabledAnimeSources() += source.id.toString() - } + presenter.toggleSources(enable) } private fun openChangelog() { @@ -257,21 +120,11 @@ class AnimeExtensionDetailsController(bundle: Bundle? = null) : ?.map { it.baseUrl } ?.distinct() ?: emptyList() - urls.forEach { + val cleared = urls.sumOf { network.cookieManager.remove(it.toHttpUrl()) } - logcat { "Cleared cookies for: ${urls.joinToString()}" } - } - - private fun AnimeSource.isEnabled(): Boolean { - return id.toString() !in preferences.disabledAnimeSources().get() - } - - private fun getPreferenceThemeContext(): Context { - val tv = TypedValue() - activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) - return ContextThemeWrapper(activity, tv.resourceId) + logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsHeaderAdapter.kt deleted file mode 100644 index cec3eb6395..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsHeaderAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.animeextension.details - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.ExtensionDetailHeaderBinding -import eu.kanade.tachiyomi.ui.browse.animeextension.getApplicationIcon -import eu.kanade.tachiyomi.util.system.LocaleHelper -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.view.clicks - -class AnimeExtensionDetailsHeaderAdapter(private val presenter: AnimeExtensionDetailsPresenter) : - RecyclerView.Adapter() { - - private lateinit var binding: ExtensionDetailHeaderBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { - binding = ExtensionDetailHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return HeaderViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { - holder.bind() - } - - inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val extension = presenter.extension ?: return - val context = view.context - - extension.getApplicationIcon(context)?.let { binding.icon.setImageDrawable(it) } - binding.title.text = extension.name - binding.version.text = context.getString(R.string.ext_version_info, extension.versionName) - binding.lang.text = context.getString(R.string.ext_language_info, LocaleHelper.getSourceDisplayName(extension.lang, context)) - binding.nsfw.isVisible = extension.isNsfw - binding.pkgname.text = extension.pkgName - - binding.btnUninstall.clicks() - .onEach { presenter.uninstallExtension() } - .launchIn(presenter.presenterScope) - binding.btnAppInfo.clicks() - .onEach { presenter.openInSettings() } - .launchIn(presenter.presenterScope) - - if (extension.isObsolete) { - binding.warningBanner.isVisible = true - binding.warningBanner.setText(R.string.obsolete_extension_message) - } - - if (extension.isUnofficial) { - binding.warningBanner.isVisible = true - binding.warningBanner.setText(R.string.unofficial_extension_message_aniyomi) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsPresenter.kt index e01b38f4b5..82dc4906e5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animeextension/details/AnimeExtensionDetailsPresenter.kt @@ -1,27 +1,58 @@ package eu.kanade.tachiyomi.ui.browse.animeextension.details +import android.app.Application import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources +import eu.kanade.domain.animesource.interactor.ToggleAnimeSource +import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.extension.AnimeExtensionManager import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import rx.android.schedulers.AndroidSchedulers -import uy.kohesive.injekt.injectLazy +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class AnimeExtensionDetailsPresenter( - private val controller: AnimeExtensionDetailsController, private val pkgName: String, + private val context: Application = Injekt.get(), + private val getExtensionSources: GetAnimeExtensionSources = Injekt.get(), + private val toggleSource: ToggleAnimeSource = Injekt.get(), + private val extensionManager: AnimeExtensionManager = Injekt.get(), ) : BasePresenter() { - private val extensionManager: AnimeExtensionManager by injectLazy() - val extension = extensionManager.installedExtensions.find { it.pkgName == pkgName } + private val _state: MutableStateFlow> = MutableStateFlow(emptyList()) + val sourcesState: StateFlow> = _state.asStateFlow() + override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) + val extension = extension ?: return + bindToUninstalledExtension() + + presenterScope.launchIO { + getExtensionSources.subscribe(extension) + .map { + it.sortedWith( + compareBy( + { item -> item.enabled.not() }, + { item -> if (item.labelAsName) item.source.name else LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase() }, + ), + ) + } + .collectLatest { _state.value = it } + } } private fun bindToUninstalledExtension() { @@ -45,6 +76,20 @@ class AnimeExtensionDetailsPresenter( val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", pkgName, null) } - controller.startActivity(intent) + view?.startActivity(intent) + } + + fun toggleSource(sourceId: Long) { + toggleSource.await(sourceId) + } + + fun toggleSources(enable: Boolean) { + extension?.sources?.forEach { toggleSource.await(it.id, enable) } } } + +data class AnimeExtensionSourceItem( + val source: AnimeSource, + val enabled: Boolean, + val labelAsName: Boolean, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterController.kt index 49c6cda3bd..43d3075bb7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterController.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.ui.browse.animesource import androidx.compose.runtime.Composable import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.animesource.AnimeSourceFilterScreen +import eu.kanade.presentation.browse.AnimeSourceFilterScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.ComposeController diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterPresenter.kt index 1809a6a0a7..1d8c40013e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceFilterPresenter.kt @@ -20,7 +20,7 @@ class AnimeSourceFilterPresenter( private val getLanguagesWithSources: GetLanguagesWithAnimeSources = Injekt.get(), private val toggleSource: ToggleAnimeSource = Injekt.get(), private val toggleLanguage: ToggleLanguage = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get() + private val preferences: PreferencesHelper = Injekt.get(), ) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(AnimeSourceFilterState.Loading) @@ -49,7 +49,7 @@ class AnimeSourceFilterPresenter( header + it.value.map { source -> AnimeFilterUiModel.Item( source, - source.id.toString() !in preferences.disabledSources().get() + source.id.toString() !in preferences.disabledSources().get(), ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcesController.kt similarity index 86% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcesController.kt index 7b590ea470..5da6d34d4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcesController.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import eu.kanade.domain.animesource.model.AnimeSource -import eu.kanade.presentation.animesource.AnimeSourceScreen +import eu.kanade.presentation.browse.AnimeSourcesScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController @@ -21,11 +21,7 @@ import eu.kanade.tachiyomi.ui.browse.animesource.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.main.MainActivity import uy.kohesive.injekt.injectLazy -/** - * This controller shows and manages the different catalogues enabled by the user. - * This controller should only handle UI actions, IO actions should be done by [AnimeSourcePresenter] - */ -class AnimeSourceController : SearchableComposeController() { +class AnimeSourcesController : SearchableComposeController() { private val preferences: PreferencesHelper by injectLazy() @@ -33,15 +29,14 @@ class AnimeSourceController : SearchableComposeController( setHasOptionsMenu(true) } - override fun getTitle(): String? = - resources?.getString(R.string.label_sources) + override fun getTitle(): String? = resources?.getString(R.string.label_animesources) - override fun createPresenter(): AnimeSourcePresenter = - AnimeSourcePresenter() + override fun createPresenter(): AnimeSourcesPresenter = + AnimeSourcesPresenter() @Composable override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { - AnimeSourceScreen( + AnimeSourcesScreen( nestedScrollInterop = nestedScrollInterop, presenter = presenter, onClickItem = { source -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcesPresenter.kt similarity index 94% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcePresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcesPresenter.kt index a63f7da7c0..caac490bec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/AnimeSourcesPresenter.kt @@ -6,7 +6,7 @@ import eu.kanade.domain.animesource.interactor.ToggleAnimeSource import eu.kanade.domain.animesource.interactor.ToggleAnimeSourcePin import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.model.Pin -import eu.kanade.presentation.animesource.AnimeSourceUiModel +import eu.kanade.presentation.browse.AnimeSourceUiModel import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.lang.launchIO import kotlinx.coroutines.flow.MutableStateFlow @@ -19,14 +19,14 @@ import uy.kohesive.injekt.api.get import java.util.TreeMap /** - * Presenter of [AnimeSourceController] + * Presenter of [AnimeSourcesController] * Function calls should be done from here. UI calls should be done from the controller. */ -class AnimeSourcePresenter( +class AnimeSourcesPresenter( private val getEnabledAnimeSources: GetEnabledAnimeSources = Injekt.get(), private val toggleSource: ToggleAnimeSource = Injekt.get(), - private val toggleSourcePin: ToggleAnimeSourcePin = Injekt.get() -) : BasePresenter() { + private val toggleSourcePin: ToggleAnimeSourcePin = Injekt.get(), +) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(AnimeSourceState.Loading) val state: StateFlow = _state.asStateFlow() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/AnimeSourcePager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/AnimeSourcePager.kt index 94270c18ed..abe0defcdb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/AnimeSourcePager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/AnimeSourcePager.kt @@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.util.lang.awaitSingle -open class AnimeSourcePager(val source: AnimeCatalogueSource, val query: String, val filters: AnimeFilterList) : AnimePager() { +class AnimeSourcePager(val source: AnimeCatalogueSource, val query: String, val filters: AnimeFilterList) : AnimePager() { override suspend fun requestNextPage() { val page = currentPage diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourceController.kt index a1a63d728a..dab6d8e919 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourceController.kt @@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.data.database.models.Anime import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.SourceControllerBinding +import eu.kanade.tachiyomi.ui.anime.AddDuplicateAnimeDialog import eu.kanade.tachiyomi.ui.anime.AnimeController import eu.kanade.tachiyomi.ui.animelib.ChangeAnimeCategoriesDialog import eu.kanade.tachiyomi.ui.base.controller.FabController @@ -59,9 +60,6 @@ import kotlinx.coroutines.flow.onEach import logcat.LogPriority import uy.kohesive.injekt.injectLazy -/** - * Controller to manage the catalogues available in the app. - */ open class BrowseAnimeSourceController(bundle: Bundle) : SearchableNucleusController(bundle), FabController, @@ -358,6 +356,7 @@ open class BrowseAnimeSourceController(bundle: Bundle) : when (filter) { is AnimeFilter.TriState -> filter.state = 1 is AnimeFilter.CheckBox -> filter.state = true + else -> {} } filterList = presenter.sourceFilters break@filter @@ -590,6 +589,7 @@ open class BrowseAnimeSourceController(bundle: Bundle) : override fun onItemLongClick(position: Int) { val activity = activity ?: return val anime = (adapter?.getItem(position) as? AnimeSourceItem?)?.anime ?: return + val duplicateAnime = presenter.getDuplicateAnimelibAnime(anime) if (anime.favorite) { MaterialAlertDialogBuilder(activity) @@ -605,43 +605,52 @@ open class BrowseAnimeSourceController(bundle: Bundle) : } .show() } else { - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultAnimeCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId } - - when { - // Default category set - defaultCategory != null -> { - presenter.moveAnimeToCategory(anime, defaultCategory) - - presenter.changeAnimeFavorite(anime) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_added_library)) - } + if (duplicateAnime != null) { + AddDuplicateAnimeDialog(this, duplicateAnime) { addToLibrary(anime, position) } + .showDialog(router) + } else { + addToLibrary(anime, position) + } + } + } - // Automatic 'Default' or no categories - defaultCategoryId == 0 || categories.isEmpty() -> { - presenter.moveAnimeToCategory(anime, null) + private fun addToLibrary(newAnime: Anime, position: Int) { + val activity = activity ?: return + val categories = presenter.getCategories() + val defaultCategoryId = preferences.defaultAnimeCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + + when { + // Default category set + defaultCategory != null -> { + presenter.moveAnimeToCategory(newAnime, defaultCategory) + + presenter.changeAnimeFavorite(newAnime) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_added_library)) + } - presenter.changeAnimeFavorite(anime) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_added_library)) - } + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + presenter.moveAnimeToCategory(newAnime, null) + presenter.changeAnimeFavorite(newAnime) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_added_library)) + } - // Choose a category - else -> { - val ids = presenter.getAnimeCategoryIds(anime) - val preselected = categories.map { - if (it.id in ids) { - QuadStateTextView.State.CHECKED.ordinal - } else { - QuadStateTextView.State.UNCHECKED.ordinal - } - }.toIntArray() + // Choose a category + else -> { + val ids = presenter.getAnimeCategoryIds(newAnime) + val preselected = categories.map { + if (it.id in ids) { + QuadStateTextView.State.CHECKED.ordinal + } else { + QuadStateTextView.State.UNCHECKED.ordinal + } + }.toIntArray() - ChangeAnimeCategoriesDialog(this, listOf(anime), categories, preselected) - .showDialog(router) - } + ChangeAnimeCategoriesDialog(this, listOf(newAnime), categories, preselected) + .showDialog(router) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourcePresenter.kt index 08e512d2b2..75b66bac04 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/browse/BrowseAnimeSourcePresenter.kt @@ -53,9 +53,6 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Date -/** - * Presenter of [BrowseAnimeSourceController]. - */ open class BrowseAnimeSourcePresenter( private val sourceId: Long, searchQuery: String? = null, @@ -351,6 +348,10 @@ open class BrowseAnimeSourcePresenter( return db.getCategories().executeAsBlocking() } + fun getDuplicateAnimelibAnime(anime: Anime): Anime? { + return db.getDuplicateAnimelibAnime(anime).executeAsBlocking() + } + /** * Gets the category id's the anime is in, if the anime is not in a category, returns the default id. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/globalsearch/GlobalAnimeSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/globalsearch/GlobalAnimeSearchPresenter.kt index 2b16c57b63..deb60741cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/globalsearch/GlobalAnimeSearchPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/animesource/globalsearch/GlobalAnimeSearchPresenter.kt @@ -92,7 +92,7 @@ open class GlobalAnimeSearchPresenter( } /** - * Returns a list of enabled sources ordered by language and name, with pinned catalogues + * Returns a list of enabled sources ordered by language and name, with pinned sources * prioritized. * * @return list containing enabled sources. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt deleted file mode 100644 index 89f621da27..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible - -/** - * Adapter that holds the catalogue cards. - * - * @param controller instance of [ExtensionController]. - */ -class ExtensionAdapter(controller: ExtensionController) : - FlexibleAdapter>(null, controller, true) { - - init { - setDisplayHeadersAtStartUp(true) - } - - /** - * Listener for browse item clicks. - */ - val buttonClickListener: OnButtonClickListener = controller - - interface OnButtonClickListener { - fun onButtonClick(position: Int) - fun onCancelButtonClick(position: Int) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt deleted file mode 100644 index b4edeec8fe..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt +++ /dev/null @@ -1,234 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -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.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import dev.chrisbanes.insetter.applyInsetter -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding -import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.NucleusController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.BrowseController -import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.appcompat.queryTextChanges -import reactivecircus.flowbinding.swiperefreshlayout.refreshes - -/** - * Controller to manage the catalogues available in the app. - */ -open class ExtensionController : - NucleusController(), - ExtensionAdapter.OnButtonClickListener, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - ExtensionTrustDialog.Listener { - - /** - * Adapter containing the list of manga from the catalogue. - */ - private var adapter: FlexibleAdapter>? = null - - private var extensions: List = emptyList() - - private var query = "" - - init { - setHasOptionsMenu(true) - } - - override fun getTitle(): String? { - return applicationContext?.getString(R.string.label_extensions) - } - - override fun createPresenter(): ExtensionPresenter { - return ExtensionPresenter(activity!!) - } - - override fun createBinding(inflater: LayoutInflater) = - ExtensionControllerBinding.inflate(inflater) - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - binding.recycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } - - binding.swipeRefresh.isRefreshing = true - binding.swipeRefresh.refreshes() - .onEach { presenter.findAvailableExtensions() } - .launchIn(viewScope) - - // Initialize adapter, scroll listener and recycler views - adapter = ExtensionAdapter(this) - // Create recycler and set adapter. - binding.recycler.layoutManager = LinearLayoutManager(view.context) - binding.recycler.adapter = adapter - adapter?.fastScroller = binding.fastScroller - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> expandActionViewFromInteraction = true - R.id.action_settings -> { - parentController!!.router.pushController(ExtensionFilterController()) - } - } - return super.onOptionsItemSelected(item) - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - if (type.isPush) { - presenter.findAvailableExtensions() - } - } - - override fun onButtonClick(position: Int) { - val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return - when (extension) { - is Extension.Available -> presenter.installExtension(extension) - is Extension.Untrusted -> openTrustDialog(extension) - is Extension.Installed -> { - if (!extension.hasUpdate) { - openDetails(extension) - } else { - presenter.updateExtension(extension) - } - } - } - } - - override fun onCancelButtonClick(position: Int) { - val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return - presenter.cancelInstallUpdateExtension(extension) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.browse_extensions, menu) - - val searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.maxWidth = Int.MAX_VALUE - - // Fixes problem with the overflow icon showing up in lieu of search - searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) - - if (query.isNotEmpty()) { - searchItem.expandActionView() - searchView.setQuery(query, true) - searchView.clearFocus() - } - - searchView.queryTextChanges() - .drop(1) // Drop first event after subscribed - .filter { router.backstack.lastOrNull()?.controller == this } - .onEach { - query = it.toString() - updateExtensionsList() - } - .launchIn(viewScope) - } - - override fun onItemClick(view: View, position: Int): Boolean { - val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false - when (extension) { - is Extension.Available -> presenter.installExtension(extension) - is Extension.Untrusted -> openTrustDialog(extension) - is Extension.Installed -> openDetails(extension) - } - return false - } - - override fun onItemLongClick(position: Int) { - val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return - if (extension is Extension.Installed || extension is Extension.Untrusted) { - uninstallExtension(extension.pkgName) - } - } - - private fun openDetails(extension: Extension.Installed) { - val controller = ExtensionDetailsController(extension.pkgName) - parentController!!.router.pushController(controller) - } - - private fun openTrustDialog(extension: Extension.Untrusted) { - ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName) - .showDialog(router) - } - - fun setExtensions(extensions: List) { - binding.swipeRefresh.isRefreshing = false - this.extensions = extensions - updateExtensionsList() - - // Update badge on parent controller tab - val ctrl = parentController as BrowseController - ctrl.setExtensionUpdateBadge() - ctrl.extensionListUpdateRelay.call(true) - } - - private fun updateExtensionsList() { - if (query.isNotBlank()) { - val queries = query.split(",") - adapter?.updateDataSet( - extensions.filter { - queries.any { query -> - when (it.extension) { - is Extension.Available -> { - it.extension.sources.any { - it.name.contains(query, ignoreCase = true) || - it.baseUrl.contains(query, ignoreCase = true) || - it.id == query.toLongOrNull() - } || it.extension.name.contains(query, ignoreCase = true) - } - is Extension.Installed -> { - it.extension.sources.any { - it.name.contains(query, ignoreCase = true) || - it.id == query.toLongOrNull() || - if (it is HttpSource) { it.baseUrl.contains(query, ignoreCase = true) } else false - } || it.extension.name.contains(query, ignoreCase = true) - } - is Extension.Untrusted -> it.extension.name.contains(query, ignoreCase = true) - } - } - }, - ) - } else { - adapter?.updateDataSet(extensions) - } - } - - fun downloadUpdate(item: ExtensionItem) { - adapter?.updateItem(item, item.installStep) - } - - override fun trustSignature(signatureHash: String) { - presenter.trustSignature(signatureHash) - } - - override fun uninstallExtension(pkgName: String) { - presenter.uninstallExtension(pkgName) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt index f3898d63af..2254da3ae0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt @@ -1,45 +1,27 @@ package eu.kanade.tachiyomi.ui.browse.extension -import androidx.preference.PreferenceScreen +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import eu.kanade.presentation.browse.ExtensionFilterScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.extension.ExtensionManager -import eu.kanade.tachiyomi.ui.setting.SettingsController -import eu.kanade.tachiyomi.util.preference.minusAssign -import eu.kanade.tachiyomi.util.preference.onChange -import eu.kanade.tachiyomi.util.preference.plusAssign -import eu.kanade.tachiyomi.util.preference.switchPreference -import eu.kanade.tachiyomi.util.preference.titleRes -import eu.kanade.tachiyomi.util.system.LocaleHelper -import uy.kohesive.injekt.injectLazy +import eu.kanade.tachiyomi.ui.base.controller.ComposeController -class ExtensionFilterController : SettingsController() { +class ExtensionFilterController : ComposeController() { - private val extensionManager: ExtensionManager by injectLazy() + override fun getTitle() = resources?.getString(R.string.label_extensions) - override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { - titleRes = R.string.label_extensions + override fun createPresenter(): ExtensionFilterPresenter = ExtensionFilterPresenter() - val activeLangs = preferences.enabledLanguages().get() - - val availableLangs = extensionManager.availableExtensions.groupBy { it.lang }.keys - .sortedWith(compareBy({ it !in activeLangs }, { LocaleHelper.getSourceDisplayName(it, context) })) - - availableLangs.forEach { - switchPreference { - preferenceScreen.addPreference(this) - title = LocaleHelper.getSourceDisplayName(it, context) - isPersistent = false - isChecked = it in activeLangs - - onChange { newValue -> - if (newValue as Boolean) { - preferences.enabledLanguages() += it - } else { - preferences.enabledLanguages() -= it - } - true - } - } - } + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + ExtensionFilterScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onClickLang = { language -> + presenter.toggleLanguage(language) + }, + ) } } + +data class FilterUiModel(val lang: String, val enabled: Boolean) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterPresenter.kt new file mode 100644 index 0000000000..c1e3aae66f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterPresenter.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.browse.extension + +import android.os.Bundle +import eu.kanade.domain.extension.interactor.GetExtensionLanguages +import eu.kanade.domain.source.interactor.ToggleLanguage +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ExtensionFilterPresenter( + private val getExtensionLanguages: GetExtensionLanguages = Injekt.get(), + private val toggleLanguage: ToggleLanguage = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get(), +) : BasePresenter() { + + private val _state: MutableStateFlow = MutableStateFlow(ExtensionFilterState.Loading) + val state: StateFlow = _state.asStateFlow() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + presenterScope.launchIO { + getExtensionLanguages.subscribe() + .catch { exception -> + _state.value = ExtensionFilterState.Error(exception) + } + .collectLatest(::collectLatestSourceLangMap) + } + } + + private fun collectLatestSourceLangMap(extLangs: List) { + val enabledLanguages = preferences.enabledLanguages().get() + val uiModels = extLangs.map { + FilterUiModel(it, it in enabledLanguages) + } + _state.value = ExtensionFilterState.Success(uiModels) + } + + fun toggleLanguage(language: String) { + toggleLanguage.await(language) + } +} + +sealed class ExtensionFilterState { + object Loading : ExtensionFilterState() + data class Error(val error: Throwable) : ExtensionFilterState() + data class Success(val models: List) : ExtensionFilterState() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt deleted file mode 100644 index 099ad8c88d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.annotation.SuppressLint -import android.view.View -import androidx.core.view.isVisible -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding - -class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) : - FlexibleViewHolder(view, adapter) { - - private val binding = SectionHeaderItemBinding.bind(view) - - @SuppressLint("SetTextI18n") - fun bind(item: ExtensionGroupItem) { - var text = item.name - if (item.showSize) { - text += " (${item.size})" - } - binding.title.text = text - - binding.actionButton.isVisible = item.actionLabel != null && item.actionOnClick != null - binding.actionButton.text = item.actionLabel - binding.actionButton.setOnClickListener(if (item.actionLabel != null) item.actionOnClick else null) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt deleted file mode 100644 index 53adf7588d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt +++ /dev/null @@ -1,62 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractHeaderItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R - -/** - * Item that contains the group header. - * - * @param name The header name. - * @param size The number of items in the group. - */ -data class ExtensionGroupItem( - val name: String, - val size: Int, - val showSize: Boolean = false, -) : AbstractHeaderItem() { - - var actionLabel: String? = null - var actionOnClick: (View.OnClickListener)? = null - - /** - * Returns the layout resource of this item. - */ - override fun getLayoutRes(): Int { - return R.layout.section_header_item - } - - /** - * Creates a new view holder for this item. - */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ExtensionGroupHolder { - return ExtensionGroupHolder(view, adapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ExtensionGroupHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other is ExtensionGroupItem) { - return name == other.name - } - return false - } - - override fun hashCode(): Int { - return name.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt deleted file mode 100644 index 28041c5564..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.view.View -import androidx.core.view.isVisible -import coil.dispose -import coil.load -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.ExtensionItemBinding -import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.util.system.LocaleHelper - -class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : - FlexibleViewHolder(view, adapter) { - - private val binding = ExtensionItemBinding.bind(view) - - init { - binding.extButton.setOnClickListener { - adapter.buttonClickListener.onButtonClick(bindingAdapterPosition) - } - binding.cancelButton.setOnClickListener { - adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition) - } - } - - fun bind(item: ExtensionItem) { - val extension = item.extension - - binding.name.text = extension.name - binding.version.text = extension.versionName - binding.lang.text = LocaleHelper.getSourceDisplayName(extension.lang, itemView.context) - binding.warning.text = when { - extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted) - extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial) - extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete) - extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short) - else -> "" - }.uppercase() - - binding.icon.dispose() - if (extension is Extension.Available) { - binding.icon.load(extension.iconUrl) - } else if (extension is Extension.Installed) { - binding.icon.load(extension.icon) - } - bindButtons(item) - } - - @Suppress("ResourceType") - fun bindButtons(item: ExtensionItem) = with(binding.extButton) { - val extension = item.extension - - val installStep = item.installStep - setText( - when (installStep) { - InstallStep.Pending -> R.string.ext_pending - InstallStep.Downloading -> R.string.ext_downloading - InstallStep.Installing -> R.string.ext_installing - InstallStep.Installed -> R.string.ext_installed - InstallStep.Error -> R.string.action_retry - InstallStep.Idle -> { - when (extension) { - is Extension.Installed -> { - if (extension.hasUpdate) { - R.string.ext_update - } else { - R.string.action_settings - } - } - is Extension.Untrusted -> R.string.ext_trust - is Extension.Available -> R.string.ext_install - } - } - }, - ) - - val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error - binding.cancelButton.isVisible = !isIdle - isEnabled = isIdle - isClickable = isIdle - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt deleted file mode 100644 index 5e895f6b5a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt +++ /dev/null @@ -1,65 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -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.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.source.CatalogueSource - -/** - * Item that contains source information. - * - * @param source Instance of [CatalogueSource] containing source information. - * @param header The header for this item. - */ -data class ExtensionItem( - val extension: Extension, - val header: ExtensionGroupItem? = null, - val installStep: InstallStep = InstallStep.Idle, -) : - AbstractSectionableItem(header) { - - /** - * Returns the layout resource of this item. - */ - override fun getLayoutRes(): Int { - return R.layout.extension_item - } - - /** - * Creates a new view holder for this item. - */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): ExtensionHolder { - return ExtensionHolder(view, adapter as ExtensionAdapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ExtensionHolder, - position: Int, - payloads: List?, - ) { - if (payloads == null || payloads.isEmpty()) { - holder.bind(this) - } else { - holder.bindButtons(this) - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - return extension.pkgName == (other as ExtensionItem).extension.pkgName - } - - override fun hashCode(): Int { - return extension.pkgName.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt deleted file mode 100644 index aeed0759c7..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt +++ /dev/null @@ -1,180 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.content.Context -import android.os.Bundle -import android.view.View -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferenceValues -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.ExtensionManager -import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.system.LocaleHelper -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit - -private typealias ExtensionTuple = - Triple, List, List> - -/** - * Presenter of [ExtensionController]. - */ -open class ExtensionPresenter( - private val context: Context, - private val extensionManager: ExtensionManager = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get(), -) : BasePresenter() { - - private var extensions = emptyList() - - private var currentDownloads = hashMapOf() - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - extensionManager.findAvailableExtensions() - bindToExtensionsObservable() - } - - private fun bindToExtensionsObservable(): Subscription { - val installedObservable = extensionManager.getInstalledExtensionsObservable() - val untrustedObservable = extensionManager.getUntrustedExtensionsObservable() - val availableObservable = extensionManager.getAvailableExtensionsObservable() - .startWith(emptyList()) - - return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) } - .debounce(500, TimeUnit.MILLISECONDS) - .map(::toItems) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) }) - } - - @Synchronized - private fun toItems(tuple: ExtensionTuple): List { - val activeLangs = preferences.enabledLanguages().get() - val showNsfwSources = preferences.showNsfwSource().get() - - val (installed, untrusted, available) = tuple - - val items = mutableListOf() - - val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) } - .sortedWith( - compareBy { !it.isObsolete } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, - ) - - val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - val availableSorted = available - // Filter out already installed extensions and disabled languages - .filter { avail -> - installed.none { it.pkgName == avail.pkgName } && - untrusted.none { it.pkgName == avail.pkgName } && - avail.lang in activeLangs && - (showNsfwSources || !avail.isNsfw) - } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - - if (updatesSorted.isNotEmpty()) { - val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true) - if (preferences.extensionInstaller().get() != PreferenceValues.ExtensionInstaller.LEGACY) { - header.actionLabel = context.getString(R.string.ext_update_all) - header.actionOnClick = View.OnClickListener { _ -> - extensions - .filter { it.extension is Extension.Installed && it.extension.hasUpdate } - .forEach { updateExtension(it.extension as Extension.Installed) } - } - } - items += updatesSorted.map { extension -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) - } - } - if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) { - val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size) - - items += installedSorted.map { extension -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) - } - - items += untrustedSorted.map { extension -> - ExtensionItem(extension, header) - } - } - if (availableSorted.isNotEmpty()) { - val availableGroupedByLang = availableSorted - .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } - .toSortedMap() - - availableGroupedByLang - .forEach { - val header = ExtensionGroupItem(it.key, it.value.size) - items += it.value.map { extension -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) - } - } - } - - this.extensions = items - return items - } - - @Synchronized - private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? { - val extensions = extensions.toMutableList() - val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } - - return if (position != -1) { - val item = extensions[position].copy(installStep = state) - extensions[position] = item - - this.extensions = extensions - item - } else { - null - } - } - - fun installExtension(extension: Extension.Available) { - extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) - } - - fun updateExtension(extension: Extension.Installed) { - extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) - } - - fun cancelInstallUpdateExtension(extension: Extension) { - extensionManager.cancelInstallUpdateExtension(extension) - } - - private fun Observable.subscribeToInstallUpdate(extension: Extension) { - this.doOnNext { currentDownloads[extension.pkgName] = it } - .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } - .map { state -> updateInstallStep(extension, state) } - .subscribeWithView({ view, item -> - if (item != null) { - view.downloadUpdate(item) - } - },) - } - - fun uninstallExtension(pkgName: String) { - extensionManager.uninstallExtension(pkgName) - } - - fun findAvailableExtensions() { - extensionManager.findAvailableExtensions() - } - - fun trustSignature(signatureHash: String) { - extensionManager.trustSignature(signatureHash) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt deleted file mode 100644 index 23d23a32ba..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt +++ /dev/null @@ -1,43 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.app.Dialog -import android.os.Bundle -import androidx.core.os.bundleOf -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : ExtensionTrustDialog.Listener { - - constructor(target: T, signatureHash: String, pkgName: String) : this( - bundleOf( - SIGNATURE_KEY to signatureHash, - PKGNAME_KEY to pkgName, - ), - ) { - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.untrusted_extension) - .setMessage(R.string.untrusted_extension_message) - .setPositiveButton(R.string.ext_trust) { _, _ -> - (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!) - } - .setNegativeButton(R.string.ext_uninstall) { _, _ -> - (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!) - } - .create() - } - - interface Listener { - fun trustSignature(signatureHash: String) - fun uninstallExtension(pkgName: String) - } -} - -private const val SIGNATURE_KEY = "signature_key" -private const val PKGNAME_KEY = "pkgname_key" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt deleted file mode 100644 index a4bae24846..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension - -import android.content.Context -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable -import eu.kanade.tachiyomi.extension.model.Extension - -fun Extension.getApplicationIcon(context: Context): Drawable? { - return try { - context.packageManager.getApplicationIcon(pkgName) - } catch (e: PackageManager.NameNotFoundException) { - null - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsController.kt new file mode 100644 index 0000000000..abb33f7ad8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsController.kt @@ -0,0 +1,120 @@ +package eu.kanade.tachiyomi.ui.browse.extension + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import eu.kanade.presentation.browse.ExtensionScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.ui.base.controller.ComposeController +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.browse.BrowseController +import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.appcompat.queryTextChanges + +class ExtensionsController : ComposeController() { + + private var query = "" + + init { + setHasOptionsMenu(true) + } + + override fun getTitle() = applicationContext?.getString(R.string.label_extensions) + + override fun createPresenter() = ExtensionsPresenter() + + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + ExtensionScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onLongClickItem = { extension -> + when (extension) { + is Extension.Available -> presenter.installExtension(extension) + else -> presenter.uninstallExtension(extension.pkgName) + } + }, + onClickItemCancel = { extension -> + presenter.cancelInstallUpdateExtension(extension) + }, + onClickUpdateAll = { + presenter.updateAllExtensions() + }, + onLaunched = { + val ctrl = parentController as BrowseController + ctrl.setExtensionUpdateBadge() + ctrl.extensionListUpdateRelay.call(true) + }, + onInstallExtension = { + presenter.installExtension(it) + }, + onOpenExtension = { + val controller = ExtensionDetailsController(it.pkgName) + parentController!!.router.pushController(controller) + }, + onTrustExtension = { + presenter.trustSignature(it.signatureHash) + }, + onUninstallExtension = { + presenter.uninstallExtension(it.pkgName) + }, + onUpdateExtension = { + presenter.updateExtension(it) + }, + onRefresh = { + presenter.findAvailableExtensions() + }, + ) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_search -> expandActionViewFromInteraction = true + R.id.action_settings -> { + parentController!!.router.pushController(ExtensionFilterController()) + } + } + return super.onOptionsItemSelected(item) + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + if (type.isPush) { + presenter.findAvailableExtensions() + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.browse_extensions, menu) + + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + // Fixes problem with the overflow icon showing up in lieu of search + searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) + + if (query.isNotEmpty()) { + searchItem.expandActionView() + searchView.setQuery(query, true) + searchView.clearFocus() + } + + searchView.queryTextChanges() + .filter { router.backstack.lastOrNull()?.controller == this } + .onEach { + query = it.toString() + presenter.search(query) + } + .launchIn(viewScope) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt new file mode 100644 index 0000000000..48cbe3e5fd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt @@ -0,0 +1,224 @@ +package eu.kanade.tachiyomi.ui.browse.extension + +import android.app.Application +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.domain.extension.interactor.GetExtensionUpdates +import eu.kanade.domain.extension.interactor.GetExtensions +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ExtensionsPresenter( + private val extensionManager: ExtensionManager = Injekt.get(), + private val getExtensionUpdates: GetExtensionUpdates = Injekt.get(), + private val getExtensions: GetExtensions = Injekt.get(), +) : BasePresenter() { + + private val _query: MutableStateFlow = MutableStateFlow("") + + private var _currentDownloads = MutableStateFlow>(hashMapOf()) + + private val _state: MutableStateFlow = MutableStateFlow(ExtensionState.Uninitialized) + val state: StateFlow = _state.asStateFlow() + + var isRefreshing: Boolean by mutableStateOf(true) + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + extensionManager.findAvailableExtensions() + + val context = Injekt.get() + val extensionMapper: (Map) -> ((Extension) -> ExtensionUiModel) = { map -> + { + ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle) + } + } + val queryFilter: (String) -> ((Extension) -> Boolean) = { query -> + filter@{ extension -> + if (query.isEmpty()) return@filter true + query.split(",").any { _input -> + val input = _input.trim() + if (input.isEmpty()) return@any false + when (extension) { + is Extension.Available -> { + extension.sources.any { + it.name.contains(input, ignoreCase = true) || + it.baseUrl.contains(input, ignoreCase = true) || + it.id == input.toLongOrNull() + } || extension.name.contains(input, ignoreCase = true) + } + is Extension.Installed -> { + extension.sources.any { + it.name.contains(input, ignoreCase = true) || + it.id == input.toLongOrNull() || + if (it is HttpSource) { it.baseUrl.contains(input, ignoreCase = true) } else false + } || extension.name.contains(input, ignoreCase = true) + } + is Extension.Untrusted -> extension.name.contains(input, ignoreCase = true) + } + } + } + } + + launchIO { + combine( + _query, + getExtensions.subscribe(), + getExtensionUpdates.subscribe(), + _currentDownloads, + ) { query, (installed, untrusted, available), updates, downloads -> + isRefreshing = false + + val languagesWithExtensions = available + .filter(queryFilter(query)) + .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } + .toSortedMap() + .flatMap { (key, value) -> + listOf( + ExtensionUiModel.Header.Text(key), + *value.map(extensionMapper(downloads)).toTypedArray(), + ) + } + + val items = mutableListOf() + + val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads)) + if (updates.isNotEmpty()) { + items.add(ExtensionUiModel.Header.Resource(R.string.ext_updates_pending)) + items.addAll(updates) + } + + val installed = installed.filter(queryFilter(query)).map(extensionMapper(downloads)) + val untrusted = untrusted.filter(queryFilter(query)).map(extensionMapper(downloads)) + if (installed.isNotEmpty() || untrusted.isNotEmpty()) { + items.add(ExtensionUiModel.Header.Resource(R.string.ext_installed)) + items.addAll(installed) + items.addAll(untrusted) + } + + if (languagesWithExtensions.isNotEmpty()) { + items.addAll(languagesWithExtensions) + } + + items + }.collectLatest { + _state.value = ExtensionState.Initialized(it) + } + } + } + + fun search(query: String) { + launchIO { + _query.emit(query) + } + } + + fun updateAllExtensions() { + launchIO { + val state = _state.value + if (state !is ExtensionState.Initialized) return@launchIO + state.list.mapNotNull { + if (it !is ExtensionUiModel.Item) return@mapNotNull null + if (it.extension !is Extension.Installed) return@mapNotNull null + if (it.extension.hasUpdate.not()) return@mapNotNull null + it.extension + }.forEach { + updateExtension(it) + } + } + } + + fun installExtension(extension: Extension.Available) { + extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) + } + + fun updateExtension(extension: Extension.Installed) { + extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) + } + + fun cancelInstallUpdateExtension(extension: Extension) { + extensionManager.cancelInstallUpdateExtension(extension) + } + + private fun removeDownloadState(extension: Extension) { + _currentDownloads.update { map -> + val map = map.toMutableMap() + map.remove(extension.pkgName) + map + } + } + + private fun addDownloadState(extension: Extension, installStep: InstallStep) { + _currentDownloads.update { map -> + val map = map.toMutableMap() + map[extension.pkgName] = installStep + map + } + } + + private fun Observable.subscribeToInstallUpdate(extension: Extension) { + this + .doOnUnsubscribe { removeDownloadState(extension) } + .subscribe( + { installStep -> addDownloadState(extension, installStep) }, + { removeDownloadState(extension) }, + ) + } + + fun uninstallExtension(pkgName: String) { + extensionManager.uninstallExtension(pkgName) + } + + fun findAvailableExtensions() { + isRefreshing = true + extensionManager.findAvailableExtensions() + } + + fun trustSignature(signatureHash: String) { + extensionManager.trustSignature(signatureHash) + } +} + +sealed interface ExtensionUiModel { + sealed interface Header : ExtensionUiModel { + data class Resource(@StringRes val textRes: Int) : Header + data class Text(val text: String) : Header + } + data class Item( + val extension: Extension, + val installStep: InstallStep, + ) : ExtensionUiModel { + + fun key(): String { + return when (extension) { + is Extension.Installed -> + if (extension.hasUpdate) "update_${extension.pkgName}" else extension.pkgName + else -> extension.pkgName + } + } + } +} + +sealed class ExtensionState { + object Uninitialized : ExtensionState() + data class Initialized(val list: List) : ExtensionState() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt index 4afa9122db..a355404a08 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt @@ -1,59 +1,30 @@ package eu.kanade.tachiyomi.ui.browse.extension.details import android.annotation.SuppressLint -import android.content.Context import android.os.Bundle -import android.util.TypedValue -import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.View -import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.core.os.bundleOf -import androidx.preference.PreferenceGroupAdapter -import androidx.preference.PreferenceManager -import androidx.preference.PreferenceScreen -import androidx.preference.SwitchPreferenceCompat -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.LinearLayoutManager -import dev.chrisbanes.insetter.applyInsetter +import eu.kanade.presentation.browse.ExtensionDetailsScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.EmptyPreferenceDataStore -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.databinding.ExtensionDetailControllerBinding -import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.ConfigurableSource -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.getPreferenceKey import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.openInBrowser import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.util.preference.DSL -import eu.kanade.tachiyomi.util.preference.minusAssign -import eu.kanade.tachiyomi.util.preference.onChange -import eu.kanade.tachiyomi.util.preference.plusAssign -import eu.kanade.tachiyomi.util.preference.switchPreference -import eu.kanade.tachiyomi.util.preference.switchSettingsPreference -import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.logcat -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import okhttp3.HttpUrl.Companion.toHttpUrl import uy.kohesive.injekt.injectLazy @SuppressLint("RestrictedApi") class ExtensionDetailsController(bundle: Bundle? = null) : - NucleusController(bundle) { + ComposeController(bundle) { - private val preferences: PreferencesHelper by injectLazy() private val network: NetworkHelper by injectLazy() - private var preferenceScreen: PreferenceScreen? = null - constructor(pkgName: String) : this( bundleOf(PKGNAME_KEY to pkgName), ) @@ -62,122 +33,22 @@ class ExtensionDetailsController(bundle: Bundle? = null) : setHasOptionsMenu(true) } - override fun createBinding(inflater: LayoutInflater): ExtensionDetailControllerBinding { - val themedInflater = inflater.cloneInContext(getPreferenceThemeContext()) - return ExtensionDetailControllerBinding.inflate(themedInflater) - } - - override fun createPresenter(): ExtensionDetailsPresenter { - return ExtensionDetailsPresenter(this, args.getString(PKGNAME_KEY)!!) - } - - override fun getTitle(): String? { - return resources?.getString(R.string.label_extension_info) - } - - @SuppressLint("PrivateResource") - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - binding.extensionPrefsRecycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } + override fun getTitle() = resources?.getString(R.string.label_extension_info) - val extension = presenter.extension ?: return - val context = view.context + override fun createPresenter() = ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!) - binding.extensionPrefsRecycler.layoutManager = LinearLayoutManager(context) - binding.extensionPrefsRecycler.adapter = ConcatAdapter( - ExtensionDetailsHeaderAdapter(presenter), - initPreferencesAdapter(context, extension), + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + ExtensionDetailsScreen( + nestedScrollInterop = nestedScrollInterop, + presenter = presenter, + onClickUninstall = { presenter.uninstallExtension() }, + onClickAppInfo = { presenter.openInSettings() }, + onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) }, + onClickSource = { presenter.toggleSource(it) }, ) } - private fun initPreferencesAdapter(context: Context, extension: Extension.Installed): PreferenceGroupAdapter { - val themedContext = getPreferenceThemeContext() - val manager = PreferenceManager(themedContext) - manager.preferenceDataStore = EmptyPreferenceDataStore() - val screen = manager.createPreferenceScreen(themedContext) - preferenceScreen = screen - - val isMultiSource = extension.sources.size > 1 - val isMultiLangSingleSource = isMultiSource && extension.sources.map { it.name }.distinct().size == 1 - - with(screen) { - if (isMultiSource && isMultiLangSingleSource.not()) { - multiLanguagePreference(context, extension.sources) - } else { - singleLanguagePreference(context, extension.sources) - } - } - - return PreferenceGroupAdapter(screen) - } - - private fun PreferenceScreen.singleLanguagePreference(context: Context, sources: List) { - sources - .map { source -> LocaleHelper.getSourceDisplayName(source.lang, context) to source } - .sortedWith(compareBy({ (_, source) -> !source.isEnabled() }, { (lang, _) -> lang.lowercase() })) - .forEach { (lang, source) -> - sourceSwitchPreference(source, lang) - } - } - - private fun PreferenceScreen.multiLanguagePreference(context: Context, sources: List) { - sources - .groupBy { (it as CatalogueSource).lang } - .toSortedMap(compareBy { LocaleHelper.getSourceDisplayName(it, context) }) - .forEach { entry -> - entry.value - .sortedWith(compareBy({ source -> !source.isEnabled() }, { source -> source.name.lowercase() })) - .forEach { source -> - sourceSwitchPreference(source, source.toString()) - } - } - } - - private fun PreferenceScreen.sourceSwitchPreference(source: Source, name: String) { - val block: (@DSL SwitchPreferenceCompat).() -> Unit = { - key = source.getPreferenceKey() - title = name - isPersistent = false - isChecked = source.isEnabled() - - onChange { newValue -> - val checked = newValue as Boolean - toggleSource(source, checked) - true - } - - // React to enable/disable all changes - preferences.disabledSources().asFlow() - .onEach { - val enabled = source.isEnabled() - isChecked = enabled - } - .launchIn(viewScope) - } - - // Source enable/disable - if (source is ConfigurableSource) { - switchSettingsPreference { - block() - onSettingsClick = View.OnClickListener { - router.pushController(SourcePreferencesController(source.id)) - } - } - } else { - switchPreference(block) - } - } - - override fun onDestroyView(view: View) { - preferenceScreen = null - super.onDestroyView(view) - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.extension_details, menu) @@ -203,15 +74,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) : } private fun toggleAllSources(enable: Boolean) { - presenter.extension?.sources?.forEach { toggleSource(it, enable) } - } - - private fun toggleSource(source: Source, enable: Boolean) { - if (enable) { - preferences.disabledSources() -= source.id.toString() - } else { - preferences.disabledSources() += source.id.toString() - } + presenter.toggleSources(enable) } private fun openChangelog() { @@ -263,16 +126,6 @@ class ExtensionDetailsController(bundle: Bundle? = null) : logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" } } - - private fun Source.isEnabled(): Boolean { - return id.toString() !in preferences.disabledSources().get() - } - - private fun getPreferenceThemeContext(): Context { - val tv = TypedValue() - activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) - return ContextThemeWrapper(activity, tv.resourceId) - } } private const val PKGNAME_KEY = "pkg_name" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsHeaderAdapter.kt deleted file mode 100644 index 37905bfa68..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsHeaderAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.extension.details - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.ExtensionDetailHeaderBinding -import eu.kanade.tachiyomi.ui.browse.extension.getApplicationIcon -import eu.kanade.tachiyomi.util.system.LocaleHelper -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import reactivecircus.flowbinding.android.view.clicks - -class ExtensionDetailsHeaderAdapter(private val presenter: ExtensionDetailsPresenter) : - RecyclerView.Adapter() { - - private lateinit var binding: ExtensionDetailHeaderBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { - binding = ExtensionDetailHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return HeaderViewHolder(binding.root) - } - - override fun getItemCount(): Int = 1 - - override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { - holder.bind() - } - - inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { - fun bind() { - val extension = presenter.extension ?: return - val context = view.context - - extension.getApplicationIcon(context)?.let { binding.icon.setImageDrawable(it) } - binding.title.text = extension.name - binding.version.text = context.getString(R.string.ext_version_info, extension.versionName) - binding.lang.text = context.getString(R.string.ext_language_info, LocaleHelper.getSourceDisplayName(extension.lang, context)) - binding.nsfw.isVisible = extension.isNsfw - binding.pkgname.text = extension.pkgName - - binding.btnUninstall.clicks() - .onEach { presenter.uninstallExtension() } - .launchIn(presenter.presenterScope) - binding.btnAppInfo.clicks() - .onEach { presenter.openInSettings() } - .launchIn(presenter.presenterScope) - - if (extension.isObsolete) { - binding.warningBanner.isVisible = true - binding.warningBanner.setText(R.string.obsolete_extension_message) - } - - if (extension.isUnofficial) { - binding.warningBanner.isVisible = true - binding.warningBanner.setText(R.string.unofficial_extension_message_tachiyomi) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt index bfe30a46a6..0a0eecd133 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt @@ -1,27 +1,58 @@ package eu.kanade.tachiyomi.ui.browse.extension.details +import android.app.Application import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings +import eu.kanade.domain.extension.interactor.GetExtensionSources +import eu.kanade.domain.source.interactor.ToggleSource import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import rx.android.schedulers.AndroidSchedulers -import uy.kohesive.injekt.injectLazy +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class ExtensionDetailsPresenter( - private val controller: ExtensionDetailsController, private val pkgName: String, + private val context: Application = Injekt.get(), + private val getExtensionSources: GetExtensionSources = Injekt.get(), + private val toggleSource: ToggleSource = Injekt.get(), + private val extensionManager: ExtensionManager = Injekt.get(), ) : BasePresenter() { - private val extensionManager: ExtensionManager by injectLazy() - val extension = extensionManager.installedExtensions.find { it.pkgName == pkgName } + private val _state: MutableStateFlow> = MutableStateFlow(emptyList()) + val sourcesState: StateFlow> = _state.asStateFlow() + override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) + val extension = extension ?: return + bindToUninstalledExtension() + + presenterScope.launchIO { + getExtensionSources.subscribe(extension) + .map { + it.sortedWith( + compareBy( + { item -> item.enabled.not() }, + { item -> if (item.labelAsName) item.source.name else LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase() }, + ), + ) + } + .collectLatest { _state.value = it } + } } private fun bindToUninstalledExtension() { @@ -45,6 +76,20 @@ class ExtensionDetailsPresenter( val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", pkgName, null) } - controller.startActivity(intent) + view?.startActivity(intent) + } + + fun toggleSource(sourceId: Long) { + toggleSource.await(sourceId) + } + + fun toggleSources(enable: Boolean) { + extension?.sources?.forEach { toggleSource.await(it.id, enable) } } } + +data class ExtensionSourceItem( + val source: Source, + val enabled: Boolean, + val labelAsName: Boolean, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimeController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimeController.kt index 46679cc62a..9dd02112f1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimeController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimeController.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.core.os.bundleOf -import eu.kanade.presentation.animesource.MigrateAnimeScreen +import eu.kanade.presentation.browse.MigrateAnimeScreen import eu.kanade.tachiyomi.ui.anime.AnimeController import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController @@ -42,7 +42,7 @@ class MigrationAnimeController : ComposeController { }, onClickCover = { router.pushController(AnimeController(it.id)) - } + }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimePresenter.kt index b7b1ffbd99..8d8f9e3225 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/anime/MigrationAnimePresenter.kt @@ -15,7 +15,7 @@ import uy.kohesive.injekt.api.get class MigrationAnimePresenter( private val sourceId: Long, - private val getFavoritesBySourceId: GetFavoritesBySourceId = Injekt.get() + private val getFavoritesBySourceId: GetFavoritesBySourceId = Injekt.get(), ) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(MigrateAnimeState.Loading) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesController.kt index e56e3649da..c110398817 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesController.kt @@ -5,7 +5,7 @@ import android.view.MenuInflater import android.view.MenuItem import androidx.compose.runtime.Composable import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import eu.kanade.presentation.animesource.MigrateAnimeSourceScreen +import eu.kanade.presentation.browse.MigrateAnimeSourceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController @@ -30,8 +30,8 @@ class MigrationAnimeSourcesController : ComposeController @@ -51,12 +51,14 @@ class MigrationAnimeSourcesController : ComposeController { + R.id.desc_alphabetical, + -> { presenter.setAlphabeticalSorting(itemId == R.id.asc_alphabetical) true } R.id.asc_count, - R.id.desc_count -> { + R.id.desc_count, + -> { presenter.setTotalSorting(itemId == R.id.asc_count) true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesPresenter.kt index 2b20adf81c..4df95a710f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/animesources/MigrationAnimeSourcesPresenter.kt @@ -16,7 +16,7 @@ import uy.kohesive.injekt.api.get class MigrationAnimeSourcesPresenter( private val getSourcesWithFavoriteCount: GetAnimeSourcesWithFavoriteCount = Injekt.get(), - private val setMigrateSorting: SetMigrateSorting = Injekt.get() + private val setMigrateSorting: SetMigrateSorting = Injekt.get(), ) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(MigrateAnimeSourceState.Loading) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt index 5adece44bf..024acc78d3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.core.os.bundleOf -import eu.kanade.presentation.source.MigrateMangaScreen +import eu.kanade.presentation.browse.MigrateMangaScreen import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController @@ -42,7 +42,7 @@ class MigrationMangaController : ComposeController { }, onClickCover = { router.pushController(MangaController(it.id)) - } + }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaPresenter.kt index 156bb5cd5f..9517924727 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaPresenter.kt @@ -15,7 +15,7 @@ import uy.kohesive.injekt.api.get class MigrationMangaPresenter( private val sourceId: Long, - private val getFavoritesBySourceId: GetFavoritesBySourceId = Injekt.get() + private val getFavoritesBySourceId: GetFavoritesBySourceId = Injekt.get(), ) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(MigrateMangaState.Loading) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt index 1be394110e..8781455f5e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt @@ -28,7 +28,7 @@ class AnimeSearchController( constructor(animeId: Long) : this( Injekt.get() .getAnime(animeId) - .executeAsBlocking() + .executeAsBlocking(), ) private var newAnime: Anime? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt index 4c4dfe3bd6..49c1c17b62 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt @@ -28,7 +28,7 @@ class SearchController( constructor(mangaId: Long) : this( Injekt.get() .getManga(mangaId) - .executeAsBlocking() + .executeAsBlocking(), ) private var newManga: Manga? = null @@ -138,7 +138,10 @@ class SearchController( } (targetController as? SearchController)?.copyManga(manga, newManga) } - .setNeutralButton(android.R.string.cancel, null) + .setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ -> + dismissDialog() + router.pushController(MangaController(newManga)) + } .create() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt index 0ebd47b3c8..076402d549 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt @@ -5,7 +5,7 @@ import android.view.MenuInflater import android.view.MenuItem import androidx.compose.runtime.Composable import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import eu.kanade.presentation.source.MigrateSourceScreen +import eu.kanade.presentation.browse.MigrateSourceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController @@ -30,8 +30,8 @@ class MigrationSourcesController : ComposeController( parentController!!.router.pushController( MigrationMangaController( source.id, - source.name - ) + source.name, + ), ) }, onLongClickItem = { source -> @@ -51,12 +51,14 @@ class MigrationSourcesController : ComposeController( true } R.id.asc_alphabetical, - R.id.desc_alphabetical -> { + R.id.desc_alphabetical, + -> { presenter.setAlphabeticalSorting(itemId == R.id.asc_alphabetical) true } R.id.asc_count, - R.id.desc_count -> { + R.id.desc_count, + -> { presenter.setTotalSorting(itemId == R.id.asc_count) true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt index 019d318170..904ade7498 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt @@ -16,7 +16,7 @@ import uy.kohesive.injekt.api.get class MigrationSourcesPresenter( private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(), - private val setMigrateSorting: SetMigrateSorting = Injekt.get() + private val setMigrateSorting: SetMigrateSorting = Injekt.get(), ) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(MigrateSourceState.Loading) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt similarity index 87% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt index ed86c9d118..2a5c5b20d5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import eu.kanade.domain.source.model.Source -import eu.kanade.presentation.source.SourceScreen +import eu.kanade.presentation.browse.SourcesScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController @@ -21,11 +21,7 @@ import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.main.MainActivity import uy.kohesive.injekt.injectLazy -/** - * This controller shows and manages the different catalogues enabled by the user. - * This controller should only handle UI actions, IO actions should be done by [SourcePresenter] - */ -class SourceController : SearchableComposeController() { +class SourcesController : SearchableComposeController() { private val preferences: PreferencesHelper by injectLazy() @@ -33,15 +29,13 @@ class SourceController : SearchableComposeController() { setHasOptionsMenu(true) } - override fun getTitle(): String? = - resources?.getString(R.string.label_sources) + override fun getTitle() = resources?.getString(R.string.label_mangasources) - override fun createPresenter(): SourcePresenter = - SourcePresenter() + override fun createPresenter() = SourcesPresenter() @Composable override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { - SourceScreen( + SourcesScreen( nestedScrollInterop = nestedScrollInterop, presenter = presenter, onClickItem = { source -> @@ -57,6 +51,7 @@ class SourceController : SearchableComposeController() { presenter.togglePin(source) }, ) + LaunchedEffect(Unit) { (activity as? MainActivity)?.ready = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceFilterController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterController.kt similarity index 66% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceFilterController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterController.kt index b68cd3aa85..c46880af51 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceFilterController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterController.kt @@ -3,19 +3,19 @@ package eu.kanade.tachiyomi.ui.browse.source import androidx.compose.runtime.Composable import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import eu.kanade.domain.source.model.Source -import eu.kanade.presentation.source.SourceFilterScreen +import eu.kanade.presentation.browse.SourcesFilterScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.ComposeController -class SourceFilterController : ComposeController() { +class SourceFilterController : ComposeController() { override fun getTitle() = resources?.getString(R.string.label_sources) - override fun createPresenter(): SourceFilterPresenter = SourceFilterPresenter() + override fun createPresenter(): SourcesFilterPresenter = SourcesFilterPresenter() @Composable override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { - SourceFilterScreen( + SourcesFilterScreen( nestedScrollInterop = nestedScrollInterop, presenter = presenter, onClickLang = { language -> @@ -29,6 +29,6 @@ class SourceFilterController : ComposeController() { } sealed class FilterUiModel { - data class Header(val language: String, val isEnabled: Boolean) : FilterUiModel() - data class Item(val source: Source, val isEnabled: Boolean) : FilterUiModel() + data class Header(val language: String, val enabled: Boolean) : FilterUiModel() + data class Item(val source: Source, val enabled: Boolean) : FilterUiModel() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceFilterPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterPresenter.kt similarity index 84% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceFilterPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterPresenter.kt index d57048c6d7..d8032d15eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceFilterPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterPresenter.kt @@ -16,11 +16,11 @@ import kotlinx.coroutines.flow.collectLatest import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class SourceFilterPresenter( +class SourcesFilterPresenter( private val getLanguagesWithSources: GetLanguagesWithSources = Injekt.get(), private val toggleSource: ToggleSource = Injekt.get(), private val toggleLanguage: ToggleLanguage = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get() + private val preferences: PreferencesHelper = Injekt.get(), ) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(SourceFilterState.Loading) @@ -28,20 +28,18 @@ class SourceFilterPresenter( override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) + presenterScope.launchIO { getLanguagesWithSources.subscribe() .catch { exception -> _state.value = SourceFilterState.Error(exception) } - .collectLatest { sourceLangMap -> - val uiModels = sourceLangMap.toFilterUiModels() - _state.value = SourceFilterState.Success(uiModels) - } + .collectLatest(::collectLatestSourceLangMap) } } - private fun Map>.toFilterUiModels(): List { - return this.flatMap { + private fun collectLatestSourceLangMap(sourceLangMap: Map>) { + val uiModels = sourceLangMap.flatMap { val isLangEnabled = it.key in preferences.enabledLanguages().get() val header = listOf(FilterUiModel.Header(it.key, isLangEnabled)) @@ -49,10 +47,11 @@ class SourceFilterPresenter( header + it.value.map { source -> FilterUiModel.Item( source, - source.id.toString() !in preferences.disabledSources().get() + source.id.toString() !in preferences.disabledSources().get(), ) } } + _state.value = SourceFilterState.Success(uiModels) } fun toggleSource(source: Source) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt similarity index 86% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt index 3f30be1f9a..b1e6cb38b6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt @@ -6,7 +6,7 @@ import eu.kanade.domain.source.interactor.ToggleSource import eu.kanade.domain.source.interactor.ToggleSourcePin import eu.kanade.domain.source.model.Pin import eu.kanade.domain.source.model.Source -import eu.kanade.presentation.source.SourceUiModel +import eu.kanade.presentation.browse.SourceUiModel import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.lang.launchIO import kotlinx.coroutines.flow.MutableStateFlow @@ -18,15 +18,11 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.TreeMap -/** - * Presenter of [SourceController] - * Function calls should be done from here. UI calls should be done from the controller. - */ -class SourcePresenter( +class SourcesPresenter( private val getEnabledSources: GetEnabledSources = Injekt.get(), private val toggleSource: ToggleSource = Injekt.get(), - private val toggleSourcePin: ToggleSourcePin = Injekt.get() -) : BasePresenter() { + private val toggleSourcePin: ToggleSourcePin = Injekt.get(), +) : BasePresenter() { private val _state: MutableStateFlow = MutableStateFlow(SourceState.Loading) val state: StateFlow = _state.asStateFlow() @@ -42,9 +38,9 @@ class SourcePresenter( } } - private suspend fun collectLatestSources(sources: List) { + private fun collectLatestSources(sources: List) { val map = TreeMap> { d1, d2 -> - // Catalogues without a lang defined will be placed at the end + // Sources without a lang defined will be placed at the end when { d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1 d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 91cb893df4..f2b9dcfd71 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -38,6 +38,7 @@ import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.manga.AddDuplicateMangaDialog import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.more.MoreController import eu.kanade.tachiyomi.ui.webview.WebViewActivity @@ -59,9 +60,6 @@ import kotlinx.coroutines.flow.onEach import logcat.LogPriority import uy.kohesive.injekt.injectLazy -/** - * Controller to manage the catalogues available in the app. - */ open class BrowseSourceController(bundle: Bundle) : SearchableNucleusController(bundle), FabController, @@ -358,6 +356,7 @@ open class BrowseSourceController(bundle: Bundle) : when (filter) { is Filter.TriState -> filter.state = 1 is Filter.CheckBox -> filter.state = true + else -> {} } filterList = presenter.sourceFilters break@filter @@ -590,6 +589,7 @@ open class BrowseSourceController(bundle: Bundle) : override fun onItemLongClick(position: Int) { val activity = activity ?: return val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return + val duplicateManga = presenter.getDuplicateLibraryManga(manga) if (manga.favorite) { MaterialAlertDialogBuilder(activity) @@ -605,43 +605,53 @@ open class BrowseSourceController(bundle: Bundle) : } .show() } else { - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId } - - when { - // Default category set - defaultCategory != null -> { - presenter.moveMangaToCategory(manga, defaultCategory) - - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_added_library)) - } + if (duplicateManga != null) { + AddDuplicateMangaDialog(this, duplicateManga) { addToLibrary(manga, position) } + .showDialog(router) + } else { + addToLibrary(manga, position) + } + } + } - // Automatic 'Default' or no categories - defaultCategoryId == 0 || categories.isEmpty() -> { - presenter.moveMangaToCategory(manga, null) + private fun addToLibrary(newManga: Manga, position: Int) { + val activity = activity ?: return + val categories = presenter.getCategories() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + + when { + // Default category set + defaultCategory != null -> { + presenter.moveMangaToCategory(newManga, defaultCategory) + + presenter.changeMangaFavorite(newManga) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_added_library)) + } - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_added_library)) - } + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + presenter.moveMangaToCategory(newManga, null) - // Choose a category - else -> { - val ids = presenter.getMangaCategoryIds(manga) - val preselected = categories.map { - if (it.id in ids) { - QuadStateTextView.State.CHECKED.ordinal - } else { - QuadStateTextView.State.UNCHECKED.ordinal - } - }.toIntArray() + presenter.changeMangaFavorite(newManga) + adapter?.notifyItemChanged(position) + activity.toast(activity.getString(R.string.manga_added_library)) + } - ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) - .showDialog(router) - } + // Choose a category + else -> { + val ids = presenter.getMangaCategoryIds(newManga) + val preselected = categories.map { + if (it.id in ids) { + QuadStateTextView.State.CHECKED.ordinal + } else { + QuadStateTextView.State.UNCHECKED.ordinal + } + }.toIntArray() + + ChangeMangaCategoriesDialog(this, listOf(newManga), categories, preselected) + .showDialog(router) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt index 1bae71a314..6b0694c934 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt @@ -53,9 +53,6 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Date -/** - * Presenter of [BrowseSourceController]. - */ open class BrowseSourcePresenter( private val sourceId: Long, searchQuery: String? = null, @@ -351,6 +348,10 @@ open class BrowseSourcePresenter( return db.getCategories().executeAsBlocking() } + fun getDuplicateLibraryManga(manga: Manga): Manga? { + return db.getDuplicateLibraryManga(manga).executeAsBlocking() + } + /** * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt index 3d79701724..786ffca3a4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt @@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.util.lang.awaitSingle -open class SourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() { +class SourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() { override suspend fun requestNextPage() { val page = currentPage diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt index ddf43b40b3..0b1d04829d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt @@ -92,7 +92,7 @@ open class GlobalSearchPresenter( } /** - * Returns a list of enabled sources ordered by language and name, with pinned catalogues + * Returns a list of enabled sources ordered by language and name, with pinned sources * prioritized. * * @return list containing enabled sources. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/anime/DownloadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/anime/DownloadHolder.kt index d2d0685632..b9b2186bc0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/anime/DownloadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/anime/DownloadHolder.kt @@ -87,7 +87,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : view.popupMenu( menuRes = R.menu.download_single, initMenu = { - findItem(R.id.move_to_top).isVisible = bindingAdapterPosition != 0 + findItem(R.id.move_to_top).isVisible = bindingAdapterPosition > 1 findItem(R.id.move_to_bottom).isVisible = bindingAdapterPosition != adapter.itemCount - 1 }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/manga/DownloadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/manga/DownloadHolder.kt index d275ad1956..964ad761e8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/manga/DownloadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/manga/DownloadHolder.kt @@ -89,7 +89,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : view.popupMenu( menuRes = R.menu.download_single, initMenu = { - findItem(R.id.move_to_top).isVisible = bindingAdapterPosition != 0 + findItem(R.id.move_to_top).isVisible = bindingAdapterPosition > 1 findItem(R.id.move_to_bottom).isVisible = bindingAdapterPosition != adapter.itemCount - 1 }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index df5b9c040e..d3316ef29d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -8,7 +8,6 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode -import androidx.core.view.doOnAttach import androidx.core.view.isVisible import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType @@ -302,8 +301,10 @@ class LibraryController( onTabsSettingsChanged(firstLaunch = true) // Delay the scroll position to allow the view to be properly measured. - view.doOnAttach { - (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) + view.post { + if (isAttached) { + (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) + } } // Send the manga map to child fragments after the adapter is updated. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt index 5da32c0e99..427c9bcc56 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt @@ -390,10 +390,11 @@ class LibrarySettingsSheet( item as Item.CheckboxGroup item.checked = !item.checked when (item) { - downloadBadge -> preferences.downloadBadge().set(item.checked) - unreadBadge -> preferences.unreadBadge().set(item.checked) - localBadge -> preferences.localBadge().set(item.checked) - languageBadge -> preferences.languageBadge().set(item.checked) + downloadBadge -> preferences.downloadBadge().set((item.checked)) + unreadBadge -> preferences.unreadBadge().set((item.checked)) + localBadge -> preferences.localBadge().set((item.checked)) + languageBadge -> preferences.languageBadge().set((item.checked)) + else -> {} } adapter.notifyItemChanged(item) } @@ -418,6 +419,7 @@ class LibrarySettingsSheet( when (item) { showTabs -> preferences.categoryTabs().set(item.checked) showNumberOfItems -> preferences.categoryNumberOfItems().set(item.checked) + else -> {} } adapter.notifyItemChanged(item) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 69ad009445..979d18eca7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -214,6 +214,7 @@ class MainActivity : BaseActivity() { val container: ViewGroup = binding.controllerContainer router = Conductor.attachRouter(this, container, savedInstanceState) + .setPopRootControllerMode(Router.PopRootControllerMode.NEVER) router.addChangeListener( object : ControllerChangeHandler.ControllerChangeListener { override fun onChangeStarted( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt new file mode 100644 index 0000000000..58fbe282cc --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.ui.manga + +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.Manga +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.pushController +import uy.kohesive.injekt.injectLazy + +class AddDuplicateMangaDialog(bundle: Bundle? = null) : DialogController(bundle) { + + private val sourceManager: SourceManager by injectLazy() + + private lateinit var libraryManga: Manga + private lateinit var onAddToLibrary: () -> Unit + + constructor( + target: Controller, + libraryManga: Manga, + onAddToLibrary: () -> Unit, + ) : this() { + targetController = target + + this.libraryManga = libraryManga + this.onAddToLibrary = onAddToLibrary + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val source = sourceManager.getOrStub(libraryManga.source) + + return MaterialAlertDialogBuilder(activity!!) + .setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name)) + .setPositiveButton(activity?.getString(R.string.action_add)) { _, _ -> + onAddToLibrary() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ -> + dismissDialog() + router.pushController(MangaController(libraryManga)) + } + .setCancelable(true) + .create() + } +} 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 18daa61226..28146ca41f 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 @@ -29,7 +29,6 @@ import coil.request.ImageRequest import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import com.google.android.material.snackbar.Snackbar import dev.chrisbanes.insetter.applyInsetter @@ -47,6 +46,7 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.saver.Image import eu.kanade.tachiyomi.data.saver.Location +import eu.kanade.tachiyomi.data.track.AnimeTrackService import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch @@ -523,38 +523,19 @@ class MangaController : } else { val duplicateManga = presenter.getDuplicateLibraryManga(manga) if (duplicateManga != null) { - showAddDuplicateDialog( - manga, - duplicateManga, - ) + AddDuplicateMangaDialog(this, duplicateManga) { addToLibrary(manga) } + .showDialog(router) } else { addToLibrary(manga) } } } - private fun showAddDuplicateDialog(newManga: Manga, libraryManga: Manga) { - activity?.let { - val source = sourceManager.getOrStub(libraryManga.source) - MaterialAlertDialogBuilder(it).apply { - setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name)) - setPositiveButton(activity?.getString(R.string.action_add)) { _, _ -> - addToLibrary(newManga) - } - setNegativeButton(activity?.getString(R.string.action_cancel)) { _, _ -> } - setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ -> - router.pushController(MangaController(libraryManga)) - } - setCancelable(true) - }.create().show() - } - } - fun onTrackingClick() { trackSheet?.show() } - private fun addToLibrary(manga: Manga) { + private fun addToLibrary(newManga: Manga) { val categories = presenter.getCategories() val defaultCategoryId = preferences.defaultCategory() val defaultCategory = categories.find { it.id == defaultCategoryId } @@ -563,7 +544,7 @@ class MangaController : // Default category set defaultCategory != null -> { toggleFavorite() - presenter.moveMangaToCategory(manga, defaultCategory) + presenter.moveMangaToCategory(newManga, defaultCategory) activity?.toast(activity?.getString(R.string.manga_added_library)) activity?.invalidateOptionsMenu() } @@ -571,14 +552,14 @@ class MangaController : // Automatic 'Default' or no categories defaultCategoryId == 0 || categories.isEmpty() -> { toggleFavorite() - presenter.moveMangaToCategory(manga, null) + presenter.moveMangaToCategory(newManga, null) activity?.toast(activity?.getString(R.string.manga_added_library)) activity?.invalidateOptionsMenu() } // Choose a category else -> { - val ids = presenter.getMangaCategoryIds(manga) + val ids = presenter.getMangaCategoryIds(newManga) val preselected = categories.map { if (it.id!! in ids) { QuadStateTextView.State.CHECKED.ordinal @@ -587,24 +568,25 @@ class MangaController : } }.toIntArray() - showChangeCategoryDialog(manga, categories, preselected) + showChangeCategoryDialog(newManga, categories, preselected) } } if (source != null) { presenter.trackList .map { it.service } + .filterNot { it is AnimeTrackService } .filterIsInstance() .filter { it.accept(source!!) } .forEach { service -> launchIO { try { - service.match(manga)?.let { track -> + service.match(newManga)?.let { track -> presenter.registerTracking(track, service as TrackService) } } catch (e: Exception) { logcat(LogPriority.WARN, e) { - "Could not match manga: ${manga.title} with service $service" + "Could not match manga: ${newManga.title} with service $service" } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt index 13edc15743..18d3f76210 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt @@ -25,6 +25,10 @@ class ChapterHolder( binding.download.setOnClickListener { onDownloadClick(it, bindingAdapterPosition) } + binding.download.setOnLongClickListener { + onDownloadLongClick(bindingAdapterPosition) + true + } } fun bind(item: ChapterItem, manga: Manga) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt index b63d0db3cb..9c04ba10d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSettingsSheet.kt @@ -113,6 +113,7 @@ class ChaptersSettingsSheet( downloaded -> presenter.setDownloadedFilter(newState) unread -> presenter.setUnreadFilter(newState) bookmarked -> presenter.setBookmarkedFilter(newState) + else -> {} } initModels() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt index a3a38dd437..e2f1dcc9cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt @@ -41,4 +41,20 @@ open class BaseChapterHolder( } } } + + fun onDownloadLongClick(position: Int) { + val item = adapter.getItem(position) as? BaseChapterItem<*, *> ?: return + when (item.status) { + Download.State.NOT_DOWNLOADED, Download.State.ERROR -> { + adapter.clickListener.downloadChapter(position) + } + Download.State.DOWNLOADED, Download.State.DOWNLOADING -> { + adapter.clickListener.deleteChapter(position) + } + // Download.State.QUEUE + else -> { + adapter.clickListener.startDownloadNow(position) + } + } + } } 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 9053864d8c..17c8fa1776 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 @@ -231,12 +231,18 @@ class ReaderActivity : BaseRxActivity() { super.onSaveInstanceState(outState) } + override fun onPause() { + presenter.saveCurrentChapterReadingProgress() + super.onPause() + } + /** * Set menu visibility again on activity resume to apply immersive mode again if needed. * Helps with rotations. */ override fun onResume() { super.onResume() + presenter.setReadStartTime() setMenuVisibility(menuVisible, animate = false) } 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 8243d0078b..a608b8d0d7 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 @@ -4,10 +4,12 @@ import android.app.Application import android.net.Uri import android.os.Bundle import com.jakewharton.rxrelay.BehaviorRelay -import eu.kanade.tachiyomi.R +import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.model.ChapterUpdate +import eu.kanade.domain.history.interactor.UpsertHistory +import eu.kanade.domain.history.model.HistoryUpdate import eu.kanade.tachiyomi.data.cache.CoverCache 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.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -22,6 +24,7 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader +import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader import eu.kanade.tachiyomi.ui.reader.model.InsertPage import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage @@ -62,6 +65,8 @@ class ReaderPresenter( private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val delayedTrackingStore: DelayedTrackingStore = Injekt.get(), + private val upsertHistory: UpsertHistory = Injekt.get(), + private val updateChapter: UpdateChapter = Injekt.get(), ) : BasePresenter() { /** @@ -80,6 +85,11 @@ class ReaderPresenter( */ private var loader: ChapterLoader? = null + /** + * The time the chapter was started reading + */ + private var chapterReadStartTime: Long? = null + /** * Subscription to prevent setting chapters as active from multiple threads. */ @@ -168,8 +178,7 @@ class ReaderPresenter( val currentChapters = viewerChaptersRelay.value if (currentChapters != null) { currentChapters.unref() - saveChapterProgress(currentChapters.currChapter) - saveChapterHistory(currentChapters.currChapter) + saveReadingProgress(currentChapters.currChapter) } } @@ -200,7 +209,9 @@ class ReaderPresenter( */ fun onSaveInstanceStateNonConfigurationChange() { val currentChapter = getCurrentChapter() ?: return - saveChapterProgress(currentChapter) + launchIO { + saveChapterProgress(currentChapter) + } } /** @@ -345,6 +356,14 @@ class ReaderPresenter( * that the user doesn't have to wait too long to continue reading. */ private fun preload(chapter: ReaderChapter) { + if (chapter.pageLoader is HttpPageLoader) { + val manga = manga ?: return + val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga) + if (isDownloaded) { + chapter.state = ReaderChapter.State.Wait + } + } + if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) { return } @@ -389,7 +408,7 @@ class ReaderPresenter( if (selectedChapter != currentChapters.currChapter) { logcat { "Setting ${selectedChapter.chapter.url} as active" } - onChapterChanged(currentChapters.currChapter) + saveReadingProgress(currentChapters.currChapter) loadNewChapter(selectedChapter) } } @@ -421,41 +440,59 @@ class ReaderPresenter( } } + fun saveCurrentChapterReadingProgress() { + getCurrentChapter()?.let { saveReadingProgress(it) } + } + /** - * Called when a chapter changed from [fromChapter] to [toChapter]. It updates [fromChapter] - * on the database. + * Called when reader chapter is changed in reader or when activity is paused. */ - private fun onChapterChanged(fromChapter: ReaderChapter) { - saveChapterProgress(fromChapter) - saveChapterHistory(fromChapter) + private fun saveReadingProgress(readerChapter: ReaderChapter) { + launchIO { + saveChapterProgress(readerChapter) + saveChapterHistory(readerChapter) + } } /** - * Saves this [chapter] progress (last read page and whether it's read). + * Saves this [readerChapter] progress (last read page and whether it's read). * If incognito mode isn't on or has at least 1 tracker */ - private fun saveChapterProgress(chapter: ReaderChapter) { + private suspend fun saveChapterProgress(readerChapter: ReaderChapter) { if (!incognitoMode || hasTrackers) { - db.updateChapterProgress(chapter.chapter).asRxCompletable() - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + val chapter = readerChapter.chapter + updateChapter.await( + ChapterUpdate( + id = chapter.id!!, + read = chapter.read, + bookmark = chapter.bookmark, + lastPageRead = chapter.last_page_read.toLong(), + ), + ) } } /** - * Saves this [chapter] last read history if incognito mode isn't on. + * Saves this [readerChapter] last read history if incognito mode isn't on. */ - private fun saveChapterHistory(chapter: ReaderChapter) { + private suspend fun saveChapterHistory(readerChapter: ReaderChapter) { if (!incognitoMode) { - val history = History.create(chapter.chapter).apply { last_read = Date().time } - db.upsertHistoryLastRead(history).asRxCompletable() - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + val chapterId = readerChapter.chapter.id!! + val readAt = Date() + val sessionReadDuration = chapterReadStartTime?.let { readAt.time - it } ?: 0 + + upsertHistory.await( + HistoryUpdate(chapterId, readAt, sessionReadDuration), + ).also { + chapterReadStartTime = null + } } } + fun setReadStartTime() { + chapterReadStartTime = Date().time + } + /** * Called from the activity to preload the given [chapter]. */ @@ -621,7 +658,7 @@ class ReaderPresenter( * Shares the image of this [page] and notifies the UI with the path of the file to share. * The image must be first copied to the internal partition because there are many possible * formats it can come from, like a zipped chapter, in which case it's not possible to directly - * get a path to the file and it has to be decompresssed somewhere first. Only the last shared + * get a path to the file and it has to be decompressed somewhere first. Only the last shared * image will be kept so it won't be taking lots of internal disk space. */ fun shareImage(page: ReaderPage) { @@ -662,20 +699,22 @@ class ReaderPresenter( Observable .fromCallable { - if (manga.isLocal()) { - val context = Injekt.get() - LocalSource.updateCover(context, manga, stream()) - manga.updateCoverLastModified(db) - R.string.cover_updated - SetAsCoverResult.Success - } else { - if (manga.favorite) { - coverCache.setCustomCoverToCache(manga, stream()) + stream().use { + if (manga.isLocal()) { + val context = Injekt.get() + LocalSource.updateCover(context, manga, it) manga.updateCoverLastModified(db) coverCache.clearMemoryCache() SetAsCoverResult.Success } else { - SetAsCoverResult.AddToLibraryFirst + if (manga.favorite) { + coverCache.setCustomCoverToCache(manga, it) + manga.updateCoverLastModified(db) + coverCache.clearMemoryCache() + SetAsCoverResult.Success + } else { + SetAsCoverResult.AddToLibraryFirst + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt index 8cf75a54cb..7ce06531c8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt @@ -72,7 +72,9 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr binding.pagerPrefsGroup.tappingInverted.bindToPreference(preferences.pagerNavInverted()) binding.pagerPrefsGroup.pagerNav.bindToPreference(preferences.navigationModePager()) - + preferences.navigationModePager() + .asImmediateFlow { binding.pagerPrefsGroup.tappingInverted.isVisible = it != 5 } + .launchIn((context as ReaderActivity).lifecycleScope) // Makes so that landscape zoom gets hidden away when image scale type is not fit screen binding.pagerPrefsGroup.scaleType.bindToPreference(preferences.imageScaleType(), 1) preferences.imageScaleType() @@ -102,6 +104,9 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr binding.webtoonPrefsGroup.tappingInverted.bindToPreference(preferences.webtoonNavInverted()) binding.webtoonPrefsGroup.webtoonNav.bindToPreference(preferences.navigationModeWebtoon()) + preferences.navigationModeWebtoon() + .asImmediateFlow { binding.webtoonPrefsGroup.tappingInverted.isVisible = it != 5 } + .launchIn((context as ReaderActivity).lifecycleScope) binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(preferences.cropBordersWebtoon()) binding.webtoonPrefsGroup.webtoonSidePadding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt index be427bab36..336cd57ae3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt @@ -249,6 +249,7 @@ open class ReaderPageImageView @JvmOverloads constructor( ZoomStartPosition.LEFT -> setScaleAndCenter(scale, PointF(0F, 0F)) ZoomStartPosition.RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0F)) ZoomStartPosition.CENTER -> setScaleAndCenter(scale, center.also { it?.y = 0F }) + null -> {} } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt index 148d142b87..12b4b8dc05 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt @@ -17,7 +17,7 @@ import kotlin.math.abs /** * Implementation of a [RecyclerView] used by the webtoon reader. */ -open class WebtoonRecyclerView @JvmOverloads constructor( +class WebtoonRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index c88b405a62..9ec03ea6dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -103,6 +103,12 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr activity.requestPreloadChapter(firstItem.to) } } + + val lastIndex = layoutManager.findLastEndVisibleItemPosition() + val lastItem = adapter.items.getOrNull(lastIndex) + if (lastItem is ChapterTransition.Next && lastItem.to == null) { + activity.showMenu() + } } }, ) @@ -216,9 +222,6 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr if (toChapter != null) { logcat { "Request preload destination chapter because we're on the transition" } activity.requestPreloadChapter(toChapter) - } else if (transition is ChapterTransition.Next) { - // No more chapters, show menu because the user is probably going to close the reader - activity.showMenu() } } @@ -245,7 +248,7 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr logcat { "moveToPage" } val position = adapter.items.indexOf(page) if (position != -1) { - recycler.scrollToPosition(position) + layoutManager.scrollToPositionWithOffset(position, 0) if (layoutManager.findLastEndVisibleItemPosition() == -1) { onScrolled(pos = position) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/animeupdates/AnimeUpdatesHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/animeupdates/AnimeUpdatesHolder.kt index 1be1a533e3..72ba34d85b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/animeupdates/AnimeUpdatesHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/animeupdates/AnimeUpdatesHolder.kt @@ -29,6 +29,10 @@ class AnimeUpdatesHolder(private val view: View, private val adapter: AnimeUpdat binding.download.setOnClickListener { onAnimeDownloadClick(it, bindingAdapterPosition) } + binding.download.setOnLongClickListener { + onAnimeDownloadLongClick(it, bindingAdapterPosition) + true + } } fun bind(item: AnimeUpdatesItem) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt index 25f2c4dd60..e669fafce9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt @@ -30,6 +30,10 @@ class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter) binding.download.setOnClickListener { onDownloadClick(it, bindingAdapterPosition) } + binding.download.setOnLongClickListener { + onDownloadLongClick(bindingAdapterPosition) + true + } } fun bind(item: UpdatesItem) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index ef35d286af..b58c856f4d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -9,15 +9,16 @@ import android.webkit.WebView import androidx.core.net.toUri import androidx.preference.PreferenceScreen import com.google.android.material.dialog.MaterialAlertDialogBuilder +import eu.kanade.domain.anime.repository.AnimeRepository +import eu.kanade.domain.manga.repository.MangaRepository import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.animelib.AnimelibUpdateService import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.EpisodeCache -import eu.kanade.tachiyomi.data.database.AnimeDatabaseHelper -import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target import eu.kanade.tachiyomi.data.preference.PreferenceValues +import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE @@ -50,17 +51,21 @@ import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.toast import logcat.LogPriority import rikka.sui.Sui +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.File import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys -class SettingsAdvancedController : SettingsController() { +class SettingsAdvancedController( + private val animeRepository: AnimeRepository = Injekt.get(), + private val mangaRepository: MangaRepository = Injekt.get(), +) : SettingsController() { private val network: NetworkHelper by injectLazy() private val chapterCache: ChapterCache by injectLazy() private val episodeCache: EpisodeCache by injectLazy() - private val db: DatabaseHelper by injectLazy() - private val animedb: AnimeDatabaseHelper by injectLazy() + private val trackManager: TrackManager by injectLazy() @SuppressLint("BatteryLife") override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { @@ -217,18 +222,29 @@ class SettingsAdvancedController : SettingsController() { AnimelibUpdateService.start(context, target = AnimelibUpdateService.Target.COVERS) } } - preference { - key = "pref_refresh_library_tracking" - titleRes = R.string.pref_refresh_library_tracking - summaryRes = R.string.pref_refresh_library_tracking_summary - onClick { - LibraryUpdateService.start(context, target = Target.TRACKING) - AnimelibUpdateService.start(context, target = AnimelibUpdateService.Target.TRACKING) + if (trackManager.hasLoggedServices()) { + preference { + key = "pref_refresh_library_tracking" + titleRes = R.string.pref_refresh_library_tracking + summaryRes = R.string.pref_refresh_library_tracking_summary + + onClick { + LibraryUpdateService.start(context, target = Target.TRACKING) + AnimelibUpdateService.start(context, target = AnimelibUpdateService.Target.TRACKING) + } } } } + preference { + key = "pref_reset_viewer_flags" + titleRes = R.string.pref_reset_viewer_flags + summaryRes = R.string.pref_reset_viewer_flags_summary + + onClick { resetViewerFlags() } + } + preferenceCategory { titleRes = R.string.label_extensions @@ -287,37 +303,52 @@ class SettingsAdvancedController : SettingsController() { } private fun clearChapterAndEpisodeCache() { - if (activity == null) return + val activity = activity ?: return launchIO { try { val deletedFiles = chapterCache.clear() + episodeCache.clear() withUIContext { - activity?.toast(resources?.getString(R.string.cache_deleted, deletedFiles)) + activity.toast(resources?.getString(R.string.cache_deleted, deletedFiles)) findPreference(CLEAR_CACHE_KEY)?.summary = resources?.getString(R.string.used_cache_both, episodeCache.readableSize, chapterCache.readableSize) } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) - withUIContext { activity?.toast(R.string.cache_delete_error) } + withUIContext { activity.toast(R.string.cache_delete_error) } } } } private fun clearWebViewData() { - if (activity == null) return + val activity = activity ?: return try { - val webview = WebView(activity!!) + val webview = WebView(activity) webview.setDefaultSettings() webview.clearCache(true) webview.clearFormData() webview.clearHistory() webview.clearSslPreferences() WebStorage.getInstance().deleteAllData() - activity?.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() } - activity?.toast(R.string.webview_data_deleted) + activity.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() } + activity.toast(R.string.webview_data_deleted) } catch (e: Throwable) { logcat(LogPriority.ERROR, e) - activity?.toast(R.string.cache_delete_error) + activity.toast(R.string.cache_delete_error) + } + } + + private fun resetViewerFlags() { + val activity = activity ?: return + launchIO { + val success = mangaRepository.resetViewerFlags() && animeRepository.resetViewerFlags() + withUIContext { + val message = if (success) { + R.string.pref_reset_viewer_flags_success + } else { + R.string.pref_reset_viewer_flags_error + } + activity.toast(message) + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index ff1cdfe5d3..35a0f2eba0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -22,10 +22,8 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupRestoreService -import eu.kanade.tachiyomi.data.backup.ValidatorParseException import eu.kanade.tachiyomi.data.backup.full.FullBackupRestoreValidator import eu.kanade.tachiyomi.data.backup.full.models.BackupFull -import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestoreValidator import eu.kanade.tachiyomi.data.preference.FLAG_CATEGORIES import eu.kanade.tachiyomi.data.preference.FLAG_CHAPTERS import eu.kanade.tachiyomi.data.preference.FLAG_HISTORY @@ -311,19 +309,9 @@ class SettingsBackupController : SettingsController() { val uri: Uri = args.getParcelable(KEY_URI)!! return try { - var type = BackupConst.BACKUP_TYPE_FULL - val results = try { - FullBackupRestoreValidator().validate(activity, uri) - } catch (_: ValidatorParseException) { - type = BackupConst.BACKUP_TYPE_LEGACY - LegacyBackupRestoreValidator().validate(activity, uri) - } + val results = FullBackupRestoreValidator().validate(activity, uri) - var message = if (type == BackupConst.BACKUP_TYPE_FULL) { - activity.getString(R.string.backup_restore_content_full) - } else { - activity.getString(R.string.backup_restore_content) - } + var message = activity.getString(R.string.backup_restore_content_full) if (results.missingSources.isNotEmpty()) { message += "\n\n${activity.getString(R.string.backup_restore_missing_sources)}\n${results.missingSources.joinToString("\n") { "- $it" }}" } @@ -335,7 +323,7 @@ class SettingsBackupController : SettingsController() { .setTitle(R.string.pref_restore_backup) .setMessage(message) .setPositiveButton(R.string.action_restore) { _, _ -> - BackupRestoreService.start(activity, uri, type) + BackupRestoreService.start(activity, uri) } .create() } catch (e: Exception) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt index 013e02f9ad..a1bd0b8cb6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING +import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED @@ -191,8 +192,8 @@ class SettingsLibraryController : SettingsController() { multiSelectListPreference { bindTo(preferences.libraryUpdateDeviceRestriction()) titleRes = R.string.pref_library_update_restriction - entriesRes = arrayOf(R.string.connected_to_wifi, R.string.charging, R.string.battery_not_low) - entryValues = arrayOf(DEVICE_ONLY_ON_WIFI, DEVICE_CHARGING, DEVICE_BATTERY_NOT_LOW) + entriesRes = arrayOf(R.string.connected_to_wifi, R.string.network_not_metered, R.string.charging, R.string.battery_not_low) + entryValues = arrayOf(DEVICE_ONLY_ON_WIFI, DEVICE_NETWORK_NOT_METERED, DEVICE_CHARGING, DEVICE_BATTERY_NOT_LOW) visibleIf(preferences.libraryUpdateInterval()) { it > 0 } @@ -209,6 +210,7 @@ class SettingsLibraryController : SettingsController() { .map { when (it) { DEVICE_ONLY_ON_WIFI -> context.getString(R.string.connected_to_wifi) + DEVICE_NETWORK_NOT_METERED -> context.getString(R.string.network_not_metered) DEVICE_CHARGING -> context.getString(R.string.charging) DEVICE_BATTERY_NOT_LOW -> context.getString(R.string.battery_not_low) else -> it diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index e8af6c898f..a67f72831c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt @@ -165,6 +165,7 @@ class SettingsReaderController : SettingsController() { TappingInvertMode.BOTH.name, ) summary = "%s" + visibleIf(preferences.navigationModePager()) { it != 5 } } intListPreference { bindTo(preferences.imageScaleType()) @@ -244,6 +245,7 @@ class SettingsReaderController : SettingsController() { TappingInvertMode.BOTH.name, ) summary = "%s" + visibleIf(preferences.navigationModeWebtoon()) { it != 5 } } intListPreference { bindTo(preferences.webtoonSidePadding()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 18d11191c9..f4ad64eb44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -63,13 +63,17 @@ class SettingsTrackingController : dialog.targetController = this@SettingsTrackingController dialog.showDialog(router) } + trackPreference(trackManager.mangaUpdates) { + val dialog = TrackLoginDialog(trackManager.mangaUpdates, R.string.username) + dialog.targetController = this@SettingsTrackingController + dialog.showDialog(router) + } trackPreference(trackManager.shikimori) { activity?.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) } trackPreference(trackManager.bangumi) { activity?.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) } - infoPreference(R.string.tracking_info) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt deleted file mode 100644 index 2d749363c9..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt +++ /dev/null @@ -1,82 +0,0 @@ -package eu.kanade.tachiyomi.ui.setting.search - -import android.os.Bundle -import android.os.Parcelable -import android.util.SparseArray -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.ui.setting.SettingsController - -/** - * Adapter that holds the search cards. - * - * @param controller instance of [SettingsSearchController]. - */ -class SettingsSearchAdapter(val controller: SettingsSearchController) : - FlexibleAdapter(null, controller, true) { - - val titleClickListener: OnTitleClickListener = controller - - /** - * Bundle where the view state of the holders is saved. - */ - private var bundle = Bundle() - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List, - ) { - super.onBindViewHolder(holder, position, payloads) - restoreHolderState(holder) - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - super.onViewRecycled(holder) - saveHolderState(holder, bundle) - } - - override fun onSaveInstanceState(outState: Bundle) { - val holdersBundle = Bundle() - allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) } - outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle) - super.onSaveInstanceState(outState) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!! - } - - /** - * Saves the view state of the given holder. - * - * @param holder The holder to save. - * @param outState The bundle where the state is saved. - */ - private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) { - val key = "holder_${holder.bindingAdapterPosition}" - val holderState = SparseArray() - holder.itemView.saveHierarchyState(holderState) - outState.putSparseParcelableArray(key, holderState) - } - - /** - * Restores the view state of the given holder. - * - * @param holder The holder to restore. - */ - private fun restoreHolderState(holder: RecyclerView.ViewHolder) { - val key = "holder_${holder.bindingAdapterPosition}" - bundle.getSparseParcelableArray(key)?.let { - holder.itemView.restoreHierarchyState(it) - bundle.remove(key) - } - } - - interface OnTitleClickListener { - fun onTitleClick(ctrl: SettingsController) - } -} - -private const val HOLDER_BUNDLE_KEY = "holder_bundle" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt index 94826ef5ad..e87346390d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt @@ -1,68 +1,45 @@ package eu.kanade.tachiyomi.ui.setting.search -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 dev.chrisbanes.insetter.applyInsetter +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import eu.kanade.presentation.more.settings.SettingsSearchScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.SettingsSearchControllerBinding -import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.setting.SettingsController -/** - * This controller shows and manages the different search result in settings search. - * [SettingsSearchAdapter.OnTitleClickListener] called when preference is clicked in settings search - */ -class SettingsSearchController : - NucleusController(), - SettingsSearchAdapter.OnTitleClickListener { +class SettingsSearchController : ComposeController() { - /** - * Adapter containing search results grouped by lang. - */ - private var adapter: SettingsSearchAdapter? = null private lateinit var searchView: SearchView init { setHasOptionsMenu(true) } - override fun createBinding(inflater: LayoutInflater) = SettingsSearchControllerBinding.inflate(inflater) + override fun getTitle() = presenter.query - override fun getTitle(): String? { - return presenter.query - } + override fun createPresenter() = SettingsSearchPresenter() - /** - * Create the [SettingsSearchPresenter] used in controller. - * - * @return instance of [SettingsSearchPresenter] - */ - override fun createPresenter(): SettingsSearchPresenter { - return SettingsSearchPresenter() + @Composable + override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) { + SettingsSearchScreen( + nestedScroll = nestedScrollInterop, + presenter = presenter, + onClickResult = { controller -> + searchView.query.let { + presenter.setLastSearchQuerySearchSettings(it.toString()) + } + router.pushController(controller) + }, + ) } - /** - * Adds items to the options menu. - * - * @param menu menu containing options. - * @param inflater used to load the menu xml. - */ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.settings_main, menu) - binding.recycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } - // Initialize search menu val searchItem = menu.findItem(R.id.action_search) searchView = searchItem.actionView as SearchView @@ -70,7 +47,6 @@ class SettingsSearchController : searchView.queryHint = applicationContext?.getString(R.string.action_search_settings) searchItem.expandActionView() - setItems(getResultSet()) searchItem.setOnActionExpandListener( object : MenuItem.OnActionExpandListener { @@ -88,76 +64,17 @@ class SettingsSearchController : searchView.setOnQueryTextListener( object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - setItems(getResultSet(query)) + presenter.searchSettings(query) return false } override fun onQueryTextChange(newText: String?): Boolean { - setItems(getResultSet(newText)) + presenter.searchSettings(newText) return false } }, ) - searchView.setQuery(presenter.preferences.lastSearchQuerySearchSettings().get(), true) - } - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - adapter = SettingsSearchAdapter(this) - binding.recycler.layoutManager = LinearLayoutManager(view.context) - binding.recycler.adapter = adapter - - // load all search results - SettingsSearchHelper.initPreferenceSearchResultCollection(presenter.preferences.context) - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) - } - - override fun onSaveViewState(view: View, outState: Bundle) { - super.onSaveViewState(view, outState) - adapter?.onSaveInstanceState(outState) - } - - override fun onRestoreViewState(view: View, savedViewState: Bundle) { - super.onRestoreViewState(view, savedViewState) - adapter?.onRestoreInstanceState(savedViewState) - } - - /** - * returns a list of `SettingsSearchItem` to be shown as search results - * Future update: should we add a minimum length to the query before displaying results? Consider other languages. - */ - fun getResultSet(query: String? = null): List { - if (!query.isNullOrBlank()) { - return SettingsSearchHelper.getFilteredResults(query) - .map { SettingsSearchItem(it, null) } - } - - return mutableListOf() - } - - /** - * Add search result to adapter. - * - * @param searchResult result of search. - */ - fun setItems(searchResult: List) { - adapter?.updateDataSet(searchResult) - } - - /** - * Opens a catalogue with the given search. - */ - override fun onTitleClick(ctrl: SettingsController) { - searchView.query.let { - presenter.preferences.lastSearchQuerySearchSettings().set(it.toString()) - } - - router.pushController(ctrl) + searchView.setQuery(presenter.getLastSearchQuerySearchSettings(), true) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt index 6e886c0d6d..d2c518a94b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt @@ -48,7 +48,7 @@ object SettingsSearchHelper { * Must be called to populate `prefSearchResultList` */ @SuppressLint("RestrictedApi") - fun initPreferenceSearchResultCollection(context: Context) { + fun initPreferenceSearchResults(context: Context) { val preferenceManager = PreferenceManager(context) prefSearchResultList.clear() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt deleted file mode 100644 index 1dd79a22ca..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.kanade.tachiyomi.ui.setting.search - -import android.view.View -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.databinding.SettingsSearchControllerCardBinding -import kotlin.reflect.full.createInstance - -/** - * Holder that binds the [SettingsSearchItem] containing catalogue cards. - * - * @param view view of [SettingsSearchItem] - * @param adapter instance of [SettingsSearchAdapter] - */ -class SettingsSearchHolder(view: View, val adapter: SettingsSearchAdapter) : - FlexibleViewHolder(view, adapter) { - - private val binding = SettingsSearchControllerCardBinding.bind(view) - - init { - binding.titleWrapper.setOnClickListener { - adapter.getItem(bindingAdapterPosition)?.let { - val ctrl = it.settingsSearchResult.searchController::class.createInstance() - ctrl.preferenceKey = it.settingsSearchResult.key - - // must pass a new Controller instance to avoid this error https://github.com/bluelinelabs/Conductor/issues/446 - adapter.titleClickListener.onTitleClick(ctrl) - } - } - } - - /** - * Show the loading of source search result. - * - * @param item item of card. - */ - fun bind(item: SettingsSearchItem) { - binding.searchResultPrefTitle.text = item.settingsSearchResult.title - binding.searchResultPrefSummary.text = item.settingsSearchResult.summary - binding.searchResultPrefBreadcrumb.text = item.settingsSearchResult.breadcrumb - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt deleted file mode 100644 index 092e0178d5..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt +++ /dev/null @@ -1,57 +0,0 @@ -package eu.kanade.tachiyomi.ui.setting.search - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R - -/** - * Item that contains search result information. - * - * @param pref the source for the search results. - * @param results the search results. - */ -class SettingsSearchItem( - val settingsSearchResult: SettingsSearchHelper.SettingsSearchResult, - val results: List?, -) : - AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.settings_search_controller_card - } - - /** - * Create view holder (see [SettingsSearchAdapter]. - * - * @return holder of view. - */ - override fun createViewHolder( - view: View, - adapter: FlexibleAdapter>, - ): SettingsSearchHolder { - return SettingsSearchHolder(view, adapter as SettingsSearchAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: SettingsSearchHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this) - } - - override fun equals(other: Any?): Boolean { - if (other is SettingsSearchItem) { - return settingsSearchResult == settingsSearchResult - } - return false - } - - override fun hashCode(): Int { - return settingsSearchResult.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt index 0d03d7561b..c73b675fa7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt @@ -3,24 +3,39 @@ package eu.kanade.tachiyomi.ui.setting.search import android.os.Bundle import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -/** - * Presenter of [SettingsSearchController] - * Function calls should be done from here. UI calls should be done from the controller. - */ -open class SettingsSearchPresenter : BasePresenter() { +class SettingsSearchPresenter( + private val preferences: PreferencesHelper = Injekt.get(), +) : BasePresenter() { - val preferences: PreferencesHelper = Injekt.get() + private val _state: MutableStateFlow> = + MutableStateFlow(emptyList()) + val state: StateFlow> = _state.asStateFlow() override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - query = savedState?.getString(SettingsSearchPresenter::query.name) ?: "" // TODO - Some way to restore previous query? + + SettingsSearchHelper.initPreferenceSearchResults(preferences.context) + } + + fun getLastSearchQuerySearchSettings(): String { + return preferences.lastSearchQuerySearchSettings().get() + } + + fun setLastSearchQuerySearchSettings(query: String) { + preferences.lastSearchQuerySearchSettings().set(query) } - override fun onSave(state: Bundle) { - state.putString(SettingsSearchPresenter::query.name, query) - super.onSave(state) + fun searchSettings(query: String?) { + _state.value = if (!query.isNullOrBlank()) { + SettingsSearchHelper.getFilteredResults(query) + } else { + emptyList() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt index dd44f42be8..f9dfb076e7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt @@ -46,8 +46,8 @@ object ChapterRecognition { // Get chapter title with lower case var name = chapter.name.lowercase() - // Remove comma's from chapter. - name = name.replace(',', '.') + // Remove comma's or hyphens. + name = name.replace(',', '.').replace('-', '.') // Remove unwanted white spaces. unwantedWhiteSpace.findAll(name).let { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt index 36c4c29a46..8791a14a09 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt @@ -26,7 +26,7 @@ fun syncChaptersWithSource( db: DatabaseHelper, rawSourceChapters: List, manga: Manga, - source: Source + source: Source, ): Pair, List> { if (rawSourceChapters.isEmpty() && source !is LocalSource) { throw NoChaptersException() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt index d03cb92ca1..d751950767 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -1,5 +1,7 @@ package eu.kanade.tachiyomi.util.lang +import android.os.Build +import android.text.Html import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator import java.nio.charset.StandardCharsets import kotlin.math.floor @@ -57,3 +59,14 @@ fun String.takeBytes(n: Int): String { bytes.decodeToString(endIndex = n).replace("\uFFFD", "") } } + +/** + * HTML-decode the string + */ +fun String.htmlDecode(): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(this, Html.FROM_HTML_MODE_LEGACY).toString() + } else { + Html.fromHtml(this).toString() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt b/app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt index 772399b1a3..70ce375e10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/preference/PreferenceDSL.kt @@ -23,7 +23,6 @@ import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.widget.preference.AdaptiveTitlePreferenceCategory import eu.kanade.tachiyomi.widget.preference.IntListPreference import eu.kanade.tachiyomi.widget.preference.SwitchPreferenceCategory -import eu.kanade.tachiyomi.widget.preference.SwitchSettingsPreference @DslMarker @Target(AnnotationTarget.TYPE) @@ -56,10 +55,6 @@ inline fun PreferenceGroup.switchPreferenceCategory(block: (@DSL SwitchPreferenc return initThenAdd(SwitchPreferenceCategory(context), block) } -inline fun PreferenceGroup.switchSettingsPreference(block: (@DSL SwitchSettingsPreference).() -> Unit): SwitchSettingsPreference { - return initThenAdd(SwitchSettingsPreference(context), block) -} - inline fun PreferenceGroup.checkBoxPreference(block: (@DSL CheckBoxPreference).() -> Unit): CheckBoxPreference { return initThenAdd(CheckBoxPreference(context), block) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 0e97cae10d..b4b92eb4d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -47,6 +47,7 @@ import logcat.LogPriority import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File +import kotlin.math.max import kotlin.math.roundToInt private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720 @@ -162,6 +163,9 @@ fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermissio } } +val getDisplayMaxHeightInPx: Int + get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) } + /** * Converts to dp. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt index a596600aaa..9fec3729c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt @@ -4,26 +4,33 @@ import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.BitmapRegionDecoder import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.os.Build +import androidx.annotation.ColorInt import androidx.core.graphics.alpha import androidx.core.graphics.applyCanvas import androidx.core.graphics.blue import androidx.core.graphics.createBitmap import androidx.core.graphics.green import androidx.core.graphics.red +import com.hippo.unifile.UniFile +import logcat.LogPriority import tachiyomi.decoder.Format import tachiyomi.decoder.ImageDecoder import java.io.BufferedInputStream import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream import java.io.InputStream import java.net.URLConnection import kotlin.math.abs +import kotlin.math.min object ImageUtil { @@ -67,8 +74,7 @@ object ImageUtil { Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P else -> false } - } catch (e: Exception) { - } + } catch (e: Exception) { /* Do Nothing */ } return false } @@ -106,20 +112,9 @@ object ImageUtil { */ fun isWideImage(imageStream: BufferedInputStream): Boolean { val options = extractImageOptions(imageStream) - imageStream.reset() return options.outWidth > options.outHeight } - /** - * Check whether the image is considered a tall image. - * - * @return true if the height:width ratio is greater than 3. - */ - fun isTallImage(imageStream: InputStream): Boolean { - val options = extractImageOptions(imageStream) - return (options.outHeight / options.outWidth) > 3 - } - /** * Extract the 'side' part from imageStream and return it as InputStream. */ @@ -183,6 +178,77 @@ object ImageUtil { RIGHT, LEFT } + /** + * Check whether the image is considered a tall image. + * + * @return true if the height:width ratio is greater than 3. + */ + private fun isTallImage(imageStream: InputStream): Boolean { + val options = extractImageOptions(imageStream, false) + return (options.outHeight / options.outWidth) > 3 + } + + /** + * Splits tall images to improve performance of reader + */ + fun splitTallImage(imageFile: UniFile, imageFilePath: String): Boolean { + if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) { + return true + } + + val options = extractImageOptions(imageFile.openInputStream(), false).apply { inJustDecodeBounds = false } + // Values are stored as they get modified during split loop + val imageHeight = options.outHeight + val imageWidth = options.outWidth + + val splitHeight = getDisplayMaxHeightInPx + // -1 so it doesn't try to split when imageHeight = getDisplayHeightInPx + val partCount = (imageHeight - 1) / getDisplayMaxHeightInPx + 1 + + logcat { "Splitting ${imageHeight}px height image into $partCount part with estimated ${splitHeight}px per height" } + + val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BitmapRegionDecoder.newInstance(imageFile.openInputStream()) + } else { + @Suppress("DEPRECATION") + BitmapRegionDecoder.newInstance(imageFile.openInputStream(), false) + } + + if (bitmapRegionDecoder == null) { + logcat { "Failed to create new instance of BitmapRegionDecoder" } + return false + } + + try { + (0 until partCount).forEach { splitIndex -> + val splitPath = imageFilePath.substringBeforeLast(".") + "__${"%03d".format(splitIndex + 1)}.jpg" + + val topOffset = splitIndex * splitHeight + val outputImageHeight = min(splitHeight, imageHeight - topOffset) + val bottomOffset = topOffset + outputImageHeight + logcat { "Split #$splitIndex with topOffset=$topOffset height=$outputImageHeight bottomOffset=$bottomOffset" } + + val region = Rect(0, topOffset, imageWidth, bottomOffset) + + FileOutputStream(splitPath).use { outputStream -> + val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options) + splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + } + } + imageFile.delete() + return true + } catch (e: Exception) { + // Image splits were not successfully saved so delete them and keep the original image + (0 until partCount) + .map { imageFilePath.substringBeforeLast(".") + "__${"%03d".format(it + 1)}.jpg" } + .forEach { File(it).delete() } + logcat(LogPriority.ERROR, e) + return false + } finally { + bitmapRegionDecoder.recycle() + } + } + /** * Algorithm for determining what background to accompany a comic/manga page */ @@ -389,24 +455,28 @@ object ImageUtil { ) } - private fun Int.isDark(): Boolean = + private fun @receiver:ColorInt Int.isDark(): Boolean = red < 40 && blue < 40 && green < 40 && alpha > 200 - private fun Int.isCloseTo(other: Int): Boolean = + private fun @receiver:ColorInt Int.isCloseTo(other: Int): Boolean = abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30 - private fun Int.isWhite(): Boolean = + private fun @receiver:ColorInt Int.isWhite(): Boolean = red + blue + green > 740 /** * Used to check an image's dimensions without loading it in the memory. */ - private fun extractImageOptions(imageStream: InputStream): BitmapFactory.Options { + private fun extractImageOptions( + imageStream: InputStream, + resetAfterExtraction: Boolean = true, + ): BitmapFactory.Options { imageStream.mark(imageStream.available() + 1) val imageBytes = imageStream.readBytes() val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) + if (resetAfterExtraction) imageStream.reset() return options } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt index 53fbe632f1..e2a56a8434 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt @@ -8,7 +8,7 @@ import android.os.LocaleList import androidx.core.os.LocaleListCompat import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.ui.browse.source.SourcePresenter +import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter import uy.kohesive.injekt.injectLazy import java.util.Locale @@ -24,8 +24,8 @@ object LocaleHelper { */ fun getSourceDisplayName(lang: String?, context: Context): String { return when (lang) { - SourcePresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source) - SourcePresenter.PINNED_KEY -> context.getString(R.string.pinned_sources) + SourcesPresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source) + SourcesPresenter.PINNED_KEY -> context.getString(R.string.pinned_sources) "other" -> context.getString(R.string.other_source) "all" -> context.getString(R.string.all_lang) else -> getDisplayName(lang) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index af0b83041c..7cc8f3fba1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -11,7 +11,7 @@ import logcat.LogPriority object WebViewUtil { const val SPOOF_PACKAGE_NAME = "org.chromium.chrome" - const val MINIMUM_WEBVIEW_VERSION = 98 + const val MINIMUM_WEBVIEW_VERSION = 99 fun supportsWebView(context: Context): Boolean { try { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt index 64d47c77a4..115eae8ee5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt @@ -100,7 +100,8 @@ class TachiyomiBottomNavigationView @JvmOverloads constructor( currentAnimator = null postInvalidate() } - }) + }, + ) } internal class SavedState : AbsSavedState { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchSettingsPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchSettingsPreference.kt deleted file mode 100644 index b5530ada59..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchSettingsPreference.kt +++ /dev/null @@ -1,34 +0,0 @@ -package eu.kanade.tachiyomi.widget.preference - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import androidx.preference.PreferenceViewHolder -import androidx.preference.SwitchPreferenceCompat -import eu.kanade.tachiyomi.R - -class SwitchSettingsPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - SwitchPreferenceCompat(context, attrs) { - - var onSettingsClick: View.OnClickListener? = null - - init { - widgetLayoutResource = R.layout.pref_settings - } - - @SuppressLint("ClickableViewAccessibility") - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - - holder.findViewById(R.id.button).setOnClickListener { - onSettingsClick?.onClick(it) - } - - // Disable swiping to align with SwitchPreferenceCompat - holder.findViewById(R.id.switchWidget).setOnTouchListener { _, event -> - event.actionMasked == MotionEvent.ACTION_MOVE - } - } -} 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 38e71410f7..4c5bd324d7 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/color/slider_active_track.xml b/app/src/main/res/color/slider_active_track.xml new file mode 100644 index 0000000000..764d21bf3d --- /dev/null +++ b/app/src/main/res/color/slider_active_track.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/slider_inactive_track.xml b/app/src/main/res/color/slider_inactive_track.xml new file mode 100644 index 0000000000..0f624c1173 --- /dev/null +++ b/app/src/main/res/color/slider_inactive_track.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/ic_manga_updates.webp b/app/src/main/res/drawable-nodpi/ic_manga_updates.webp new file mode 100644 index 0000000000..eece5d7d65 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_manga_updates.webp differ diff --git a/app/src/main/res/layout/extension_controller.xml b/app/src/main/res/layout/extension_controller.xml deleted file mode 100644 index 0db6a30248..0000000000 --- a/app/src/main/res/layout/extension_controller.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/extension_detail_controller.xml b/app/src/main/res/layout/extension_detail_controller.xml deleted file mode 100644 index 321b786306..0000000000 --- a/app/src/main/res/layout/extension_detail_controller.xml +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/app/src/main/res/layout/extension_detail_header.xml b/app/src/main/res/layout/extension_detail_header.xml deleted file mode 100644 index 5a7eb8e481..0000000000 --- a/app/src/main/res/layout/extension_detail_header.xml +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - -