diff --git a/app/src/androidTest/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorkerIntegrationTest.kt b/app/src/androidTest/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorkerIntegrationTest.kt new file mode 100644 index 0000000000..facee4a43e --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorkerIntegrationTest.kt @@ -0,0 +1,90 @@ +package io.github.landwarderer.futon.download.ui.worker + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.NetworkType +import io.github.landwarderer.futon.core.prefs.DownloadFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.lang.reflect.Method + +@RunWith(AndroidJUnit4::class) +class DownloadWorkerIntegrationTest { + + private lateinit var context: Context + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun testDownloadWorkerConstraintsPropagation() { + val task = DownloadTask( + mangaId = 1L, + isPaused = false, + isSilent = false, + chaptersIds = longArrayOf(1L), + destination = File(context.cacheDir, "test"), + format = DownloadFormat.SINGLE_CBZ, + allowMeteredNetwork = false, + requiresCharging = true + ) + + // Using reflection because createConstraints is private + val constraints = callCreateConstraints(task.allowMeteredNetwork, task.requiresCharging) + + assertEquals(NetworkType.UNMETERED, constraints.requiredNetworkType) + assertTrue(constraints.requiresCharging()) + } + + @Test + fun testDownloadWorkerConstraintsPropagationMetered() { + val task = DownloadTask( + mangaId = 1L, + isPaused = false, + isSilent = false, + chaptersIds = longArrayOf(1L), + destination = File(context.cacheDir, "test"), + format = DownloadFormat.SINGLE_CBZ, + allowMeteredNetwork = true, + requiresCharging = false + ) + + val constraints = callCreateConstraints(task.allowMeteredNetwork, task.requiresCharging) + + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + assertFalse(constraints.requiresCharging()) + } + + private fun callCreateConstraints(allowMetered: Boolean, requiresCharging: Boolean): androidx.work.Constraints { + val schedulerClass = DownloadWorker.Scheduler::class.java + val method: Method = schedulerClass.getDeclaredMethod("createConstraints", Boolean::class.java, Boolean::class.java) + method.isAccessible = true + + // Find a constructor for Scheduler. It's a regular class, so we need an instance if it's not static. + // But in Kotlin, 'class' inside 'class' is static. + // However, 'createConstraints' is a private method, so we still need an instance if it's not a companion object method. + // It's a method of the Scheduler class. + + // Since we only want to test the logic of the method, and it doesn't use 'this', + // we can try to invoke it on a dummy instance if needed, or if it's static in Java. + // In Kotlin, it's a member function of Scheduler. + + // Let's just create a mock or a dummy instance of Scheduler. + // Scheduler has @Inject constructor(Context, MangaDataRepository, WorkManager) + + val constructor = schedulerClass.declaredConstructors[0] + constructor.isAccessible = true + val args = arrayOfNulls(constructor.parameterCount) + val schedulerInstance = constructor.newInstance(*args) + + return method.invoke(schedulerInstance, allowMetered, requiresCharging) as androidx.work.Constraints + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 62357e3251..530ba90fde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -241,6 +241,9 @@ + + repository.restoreBackup(input, sections, progress, isMerge) } + if (result.isAllSuccess && sections.contains(BackupSection.SETTINGS)) { + startService(Intent(this@RestoreService, LocalIndexUpdateService::class.java)) + } progressUpdateJob?.cancelAndJoin() showResultNotification(source, result) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt index 23e3e831f5..c5d07a2c12 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt @@ -25,11 +25,6 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.multibindings.ElementsIntoSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import okhttp3.OkHttpClient import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.backups.domain.BackupObserver import io.github.landwarderer.futon.core.db.MangaDatabase @@ -46,7 +41,6 @@ import io.github.landwarderer.futon.core.parser.favicon.FaviconFetcher import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.ui.image.CoilImageGetter import io.github.landwarderer.futon.core.ui.util.ActivityRecreationHandle - import io.github.landwarderer.futon.core.util.FileSize import io.github.landwarderer.futon.core.util.ext.connectivityManager import io.github.landwarderer.futon.core.util.ext.isLowRamDevice @@ -61,10 +55,15 @@ import io.github.landwarderer.futon.local.domain.model.LocalManga import io.github.landwarderer.futon.main.domain.CoverRestoreInterceptor import io.github.landwarderer.futon.main.ui.protect.AppProtectHelper import io.github.landwarderer.futon.main.ui.protect.ScreenshotPolicyHelper -import org.koitharu.kotatsu.parsers.MangaLoaderContext import io.github.landwarderer.futon.search.ui.MangaSuggestionsProvider import io.github.landwarderer.futon.sync.domain.SyncController import io.github.landwarderer.futon.widget.WidgetUpdater +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.parsers.MangaLoaderContext import javax.inject.Provider import javax.inject.Singleton @@ -197,6 +196,16 @@ interface AppModule { @ApplicationContext context: Context, ): WorkManager = WorkManager.getInstance(context) + @Provides + fun provideDownloadQueueDao( + database: MangaDatabase, + ): io.github.landwarderer.futon.download.data.dao.DownloadQueueDao = database.getDownloadQueueDao() + + @Provides + fun provideMangaDao( + database: MangaDatabase, + ): io.github.landwarderer.futon.core.db.dao.MangaDao = database.getMangaDao() + @Provides @Singleton @PageCache diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt index 284deabb4d..295d378c8c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt @@ -5,9 +5,11 @@ import androidx.room.Database import androidx.room.InvalidationTracker import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverters import androidx.room.migration.Migration import io.github.landwarderer.futon.bookmarks.data.BookmarkEntity import io.github.landwarderer.futon.bookmarks.data.BookmarksDao +import io.github.landwarderer.futon.core.db.converters.DataConverters import io.github.landwarderer.futon.core.db.dao.ChaptersDao import io.github.landwarderer.futon.core.db.dao.ExternalExtensionRepoDao import io.github.landwarderer.futon.core.db.dao.MangaDao @@ -42,6 +44,7 @@ import io.github.landwarderer.futon.core.db.migrations.Migration24To25 import io.github.landwarderer.futon.core.db.migrations.Migration25To26 import io.github.landwarderer.futon.core.db.migrations.Migration26To27 import io.github.landwarderer.futon.core.db.migrations.Migration27To28 +import io.github.landwarderer.futon.core.db.migrations.Migration28To29 import io.github.landwarderer.futon.core.db.migrations.Migration2To3 import io.github.landwarderer.futon.core.db.migrations.Migration3To4 import io.github.landwarderer.futon.core.db.migrations.Migration4To5 @@ -51,6 +54,8 @@ import io.github.landwarderer.futon.core.db.migrations.Migration7To8 import io.github.landwarderer.futon.core.db.migrations.Migration8To9 import io.github.landwarderer.futon.core.db.migrations.Migration9To10 import io.github.landwarderer.futon.core.util.ext.processLifecycleScope +import io.github.landwarderer.futon.download.data.dao.DownloadQueueDao +import io.github.landwarderer.futon.download.data.entity.DownloadQueueEntity import io.github.landwarderer.futon.favourites.data.FavouriteCategoriesDao import io.github.landwarderer.futon.favourites.data.FavouriteCategoryEntity import io.github.landwarderer.futon.favourites.data.FavouriteEntity @@ -73,7 +78,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -const val DATABASE_VERSION = 28 +const val DATABASE_VERSION = 29 @Database( entities = [ @@ -81,9 +86,11 @@ const val DATABASE_VERSION = 28 FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, ExternalExtensionRepoEntity::class, + DownloadQueueEntity::class, ], version = DATABASE_VERSION, ) +@TypeConverters(DataConverters::class) abstract class MangaDatabase : RoomDatabase() { abstract fun getHistoryDao(): HistoryDao @@ -117,6 +124,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract fun getChaptersDao(): ChaptersDao abstract fun getExternalExtensionRepoDao(): ExternalExtensionRepoDao + + abstract fun getDownloadQueueDao(): DownloadQueueDao } fun getDatabaseMigrations(context: Context): Array = arrayOf( @@ -148,6 +157,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration25To26(), Migration26To27(), Migration27To28(), + Migration28To29(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt index 69e0ef1a91..4b371462dd 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt @@ -9,3 +9,4 @@ const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_SOURCES = "sources" const val TABLE_CHAPTERS = "chapters" const val TABLE_PREFERENCES = "preferences" +const val TABLE_DOWNLOAD_QUEUE = "download_queue" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/converters/DataConverters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/converters/DataConverters.kt new file mode 100644 index 0000000000..4ccee1070d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/converters/DataConverters.kt @@ -0,0 +1,15 @@ +package io.github.landwarderer.futon.core.db.converters + +import androidx.room.TypeConverter + +class DataConverters { + @TypeConverter + fun fromLongArray(value: LongArray?): String? { + return value?.joinToString(",") + } + + @TypeConverter + fun toLongArray(value: String?): LongArray? { + return value?.split(",")?.filter { it.isNotEmpty() }?.map { it.toLong() }?.toLongArray() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt index 936058fdd5..ea2f203f95 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt @@ -61,6 +61,9 @@ abstract class TagsDao { ) abstract suspend fun findTags(query: String, limit: Int): List + @Query("SELECT * FROM tags WHERE title LIKE :query GROUP BY title LIMIT :limit") + abstract suspend fun searchAllTags(query: String, limit: Int): List + @Query( """ SELECT tags.* FROM manga_tags diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/TagEntity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/TagEntity.kt index fa9214b8a9..e64edb68c7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/TagEntity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/entity/TagEntity.kt @@ -2,10 +2,16 @@ package io.github.landwarderer.futon.core.db.entity import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import io.github.landwarderer.futon.core.db.TABLE_TAGS -@Entity(tableName = TABLE_TAGS) +@Entity( + tableName = TABLE_TAGS, + indices = [ + Index(value = ["title"]), + ], +) data class TagEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "tag_id") val id: Long, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/migrations/Migration28To29.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/migrations/Migration28To29.kt new file mode 100644 index 0000000000..46ddd60187 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/migrations/Migration28To29.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration28To29 : Migration(28, 29) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `download_queue` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `manga_id` INTEGER NOT NULL, + `chapters_ids` TEXT NOT NULL, + `priority` INTEGER NOT NULL, + `created_at` INTEGER NOT NULL, + `wifi_only` INTEGER NOT NULL, + `charging_only` INTEGER NOT NULL, + `off_peak_only` INTEGER NOT NULL, + FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_download_queue_manga_id` ON `download_queue` (`manga_id`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_tags_title` ON `tags` (`title`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_download_queue_priority` ON `download_queue` (`priority`)") + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt index 6c1fbd8f5a..660802c018 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt @@ -89,6 +89,7 @@ import io.github.landwarderer.futon.settings.SettingsActivity import io.github.landwarderer.futon.settings.about.AppUpdateActivity import io.github.landwarderer.futon.settings.override.OverrideConfigActivity import io.github.landwarderer.futon.settings.reader.ReaderTapGridConfigActivity +import io.github.landwarderer.futon.settings.sources.TagsBlacklistActivity import io.github.landwarderer.futon.settings.sources.auth.SourceAuthActivity import io.github.landwarderer.futon.settings.sources.catalog.SourcesCatalogActivity import io.github.landwarderer.futon.settings.sources.extension.ExtensionDownloaderActivity @@ -212,6 +213,10 @@ class AppRouter private constructor( fun openDownloads() = startActivity(DownloadsActivity::class.java) + fun openDownloadQueue() { + startActivity(Intent(contextOrNull() ?: return, Class.forName("io.github.landwarderer.futon.download.ui.DownloadQueueActivity"))) + } + fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java) fun openBrowser(url: String, source: MangaSource?, title: String?) { @@ -313,6 +318,8 @@ class AppRouter private constructor( ) } + fun openTagsBlacklist() = startActivity(TagsBlacklistActivity::class.java) + fun openStatistic() = startActivity(StatsActivity::class.java) @CheckResult diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt index 777b0a6fc2..99fb3347ab 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt @@ -405,6 +405,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return string.split(',').mapToSet { it.trim() } } + var tagsBlacklist: Set + get() { + val string = prefs.getString(KEY_TAGS_BLACKLIST, null)?.trimEnd(' ', ',') + if (string.isNullOrEmpty()) { + return emptySet() + } + return string.split(',').mapToSet { it.trim() } + } + set(value) = prefs.edit { putString(KEY_TAGS_BLACKLIST, value.joinToString(", ")) } + val isReaderBarEnabled: Boolean get() = prefs.getBoolean(KEY_READER_BAR, true) @@ -591,6 +601,34 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isAutoLocalChaptersCleanupEnabled: Boolean get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false) + var downloadOffPeakStart: String + get() = prefs.getString(KEY_DOWNLOAD_OFF_PEAK_START, "00:00") ?: "00:00" + set(value) = prefs.edit { putString(KEY_DOWNLOAD_OFF_PEAK_START, value) } + + var downloadOffPeakEnd: String + get() = prefs.getString(KEY_DOWNLOAD_OFF_PEAK_END, "06:00") ?: "06:00" + set(value) = prefs.edit { putString(KEY_DOWNLOAD_OFF_PEAK_END, value) } + + var isDownloadOffPeakEnabled: Boolean + get() = prefs.getBoolean(KEY_DOWNLOAD_OFF_PEAK_ENABLED, false) + set(value) = prefs.edit { putBoolean(KEY_DOWNLOAD_OFF_PEAK_ENABLED, value) } + + var downloadStorageQuota: Long + get() = prefs.getString(KEY_DOWNLOAD_STORAGE_QUOTA, null)?.toLongOrNull() ?: 0L + set(value) = prefs.edit { putString(KEY_DOWNLOAD_STORAGE_QUOTA, value.toString()) } + + var isAutoDownloadNextChapterEnabled: Boolean + get() = prefs.getBoolean(KEY_AUTO_DOWNLOAD_NEXT, false) + set(value) = prefs.edit { putBoolean(KEY_AUTO_DOWNLOAD_NEXT, value) } + + var isDownloadOnlyOnChargingEnabled: Boolean + get() = prefs.getBoolean(KEY_DOWNLOAD_ONLY_ON_CHARGING, false) + set(value) = prefs.edit { putBoolean(KEY_DOWNLOAD_ONLY_ON_CHARGING, value) } + + var downloadBandwidthLimit: Int + get() = prefs.getInt(KEY_DOWNLOAD_BANDWIDTH_LIMIT, 0) // in KB/s, 0 = unlimited + set(value) = prefs.edit { putInt(KEY_DOWNLOAD_BANDWIDTH_LIMIT, value) } + fun isPagesCropEnabled(mode: ReaderMode): Boolean { val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet()) if (rawValue.isNullOrEmpty()) { @@ -746,6 +784,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" + const val KEY_TAGS_BLACKLIST = "tags_blacklist" const val KEY_SUGGESTIONS_DISABLED_SOURCES = "suggestions_disabled_sources" const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" const val KEY_SHIKIMORI = "shikimori" @@ -831,6 +870,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_CRASH_ANALYTICS_ENABLED = "crash_analytics_enabled" const val KEY_GITHUB_MIRROR = "github_mirror" + const val KEY_DOWNLOAD_OFF_PEAK_START = "download_off_peak_start" + const val KEY_DOWNLOAD_OFF_PEAK_END = "download_off_peak_end" + const val KEY_DOWNLOAD_OFF_PEAK_ENABLED = "download_off_peak_enabled" + const val KEY_DOWNLOAD_STORAGE_QUOTA = "download_storage_quota" + const val KEY_AUTO_DOWNLOAD_NEXT = "auto_download_next" + const val KEY_DOWNLOAD_BANDWIDTH_LIMIT = "download_bandwidth_limit" + const val KEY_DOWNLOAD_ONLY_ON_CHARGING = "download_only_on_charging" + // keys for non-persistent preferences const val KEY_APP_VERSION = "app_version" const val KEY_IGNORE_DOZE = "ignore_dose" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/BandwidthLimiter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/BandwidthLimiter.kt new file mode 100644 index 0000000000..9963a60be5 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/BandwidthLimiter.kt @@ -0,0 +1,53 @@ +package io.github.landwarderer.futon.core.util + +import android.os.SystemClock +import kotlinx.coroutines.runBlocking +import okio.Buffer +import okio.ForwardingSource +import okio.Source + +class BandwidthLimitedSource( + delegate: Source, + private val limiter: BandwidthLimiter +) : ForwardingSource(delegate) { + override fun read(sink: Buffer, byteCount: Long): Long { + val read = super.read(sink, byteCount) + if (read > 0) { + limiter.take(read) + } + return read + } +} + +class BandwidthLimiter(private val bytesPerSecondProvider: () -> Int) { + private var lastTime = SystemClock.elapsedRealtime() + private var availableBytes = 0L + + fun take(bytes: Long) { + val limit = bytesPerSecondProvider() + if (limit <= 0) return + + var delayMs = 0L + synchronized(this) { + val now = SystemClock.elapsedRealtime() + val elapsed = now - lastTime + availableBytes += (elapsed * limit) / 1000 + if (availableBytes > limit) availableBytes = limit.toLong() + lastTime = now + + availableBytes -= bytes + if (availableBytes < 0) { + delayMs = (-availableBytes * 1000) / limit + } + } + + if (delayMs > 0) { + runBlocking { + kotlinx.coroutines.delay(delayMs) + } + synchronized(this) { + lastTime = SystemClock.elapsedRealtime() + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/IO.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/IO.kt index 29d699bc1d..1d20f25cf0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/IO.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/IO.kt @@ -3,6 +3,10 @@ package io.github.landwarderer.futon.core.util.ext import android.content.ContentResolver import android.net.Uri import androidx.annotation.CheckResult +import io.github.landwarderer.futon.core.util.BandwidthLimitedSource +import io.github.landwarderer.futon.core.util.BandwidthLimiter +import io.github.landwarderer.futon.core.util.CancellableSource +import io.github.landwarderer.futon.core.util.progress.ProgressResponseBody import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext @@ -16,8 +20,6 @@ import okio.IOException import okio.Path import okio.Source import okio.source -import io.github.landwarderer.futon.core.util.CancellableSource -import io.github.landwarderer.futon.core.util.progress.ProgressResponseBody import java.io.ByteArrayOutputStream import java.io.InputStream import java.nio.ByteBuffer @@ -31,8 +33,13 @@ suspend fun Source.cancellable(): Source { return CancellableSource(job, this) } -suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { - writeAll(source.cancellable()) +suspend fun BufferedSink.writeAllCancellable( + source: Source, + bandwidthLimiter: BandwidthLimiter? = null +) = withContext(Dispatchers.IO) { + val cancellable = source.cancellable() + val limited = bandwidthLimiter?.let { BandwidthLimitedSource(cancellable, it) } ?: cancellable + writeAll(limited) } fun BufferedSource.readByteBuffer(): ByteBuffer { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt index 104b8fcb9f..bf5ecf08cf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt @@ -13,7 +13,6 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.LocalMangaSource import io.github.landwarderer.futon.core.nav.AppRouter @@ -21,6 +20,7 @@ import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.os.AppShortcutManager import io.github.landwarderer.futon.core.ui.dialog.buildAlertDialog import io.github.landwarderer.futon.core.util.ext.isHttpUrl +import kotlinx.coroutines.launch class DetailsMenuProvider( private val activity: FragmentActivity, @@ -42,10 +42,11 @@ class DetailsMenuProvider( } override fun onPrepareMenu(menu: Menu) { - val manga = viewModel.manga.value + val mangaDetails = viewModel.mangaDetails.value + val manga = mangaDetails?.toManga() menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga) menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource - menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource + menu.findItem(R.id.action_delete).isVisible = mangaDetails?.local != null menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt index 6619d40315..254b05701e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt @@ -3,21 +3,6 @@ package io.github.landwarderer.futon.details.ui import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import io.github.landwarderer.futon.R import io.github.landwarderer.futon.bookmarks.domain.BookmarksRepository import io.github.landwarderer.futon.core.model.getPreferredBranch @@ -46,14 +31,29 @@ import io.github.landwarderer.futon.list.ui.model.MangaListModel import io.github.landwarderer.futon.local.data.LocalStorageChanges import io.github.landwarderer.futon.local.domain.DeleteLocalMangaUseCase import io.github.landwarderer.futon.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.findById -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.reader.ui.ReaderState import io.github.landwarderer.futon.scrobbling.common.domain.Scrobbler import io.github.landwarderer.futon.scrobbling.common.domain.model.ScrobblingInfo import io.github.landwarderer.futon.scrobbling.common.domain.model.ScrobblingStatus import io.github.landwarderer.futon.stats.data.StatsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.findById +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject import javax.inject.Provider @@ -65,6 +65,9 @@ class DetailsViewModel @Inject constructor( private val scrobblersProvider: Provider>, @LocalStorageChanges localStorageChanges: SharedFlow, downloadScheduler: DownloadWorker.Scheduler, + downloadQueueRepository: io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository, + addUnreadToQueueUseCase: io.github.landwarderer.futon.download.domain.usecase.AddUnreadToQueueUseCase, + workManager: androidx.work.WorkManager, interactor: DetailsInteractor, savedStateHandle: SavedStateHandle, deleteLocalMangaUseCase: DeleteLocalMangaUseCase, @@ -80,6 +83,9 @@ class DetailsViewModel @Inject constructor( bookmarksRepository = bookmarksRepository, historyRepository = historyRepository, downloadScheduler = downloadScheduler, + downloadQueueRepository = downloadQueueRepository, + addUnreadToQueueUseCase = addUnreadToQueueUseCase, + workManager = workManager, deleteLocalMangaUseCase = deleteLocalMangaUseCase, localStorageChanges = localStorageChanges, ) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesViewModel.kt index 8b7c4c4a23..5bd0999098 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/ChaptersPagesViewModel.kt @@ -4,21 +4,6 @@ import android.app.Activity import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.plus -import okio.FileNotFoundException import io.github.landwarderer.futon.bookmarks.domain.BookmarksRepository import io.github.landwarderer.futon.core.model.toChipModel import io.github.landwarderer.futon.core.prefs.AppSettings @@ -43,11 +28,26 @@ import io.github.landwarderer.futon.history.data.HistoryRepository import io.github.landwarderer.futon.list.domain.ListFilterOption import io.github.landwarderer.futon.local.domain.DeleteLocalMangaUseCase import io.github.landwarderer.futon.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaState import io.github.landwarderer.futon.reader.ui.ReaderActivity import io.github.landwarderer.futon.reader.ui.ReaderState import io.github.landwarderer.futon.reader.ui.ReaderViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import okio.FileNotFoundException +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaState abstract class ChaptersPagesViewModel( @JvmField protected val settings: AppSettings, @@ -55,6 +55,9 @@ abstract class ChaptersPagesViewModel( private val bookmarksRepository: BookmarksRepository, private val historyRepository: HistoryRepository, private val downloadScheduler: DownloadWorker.Scheduler, + private val downloadQueueRepository: io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository, + private val addUnreadToQueueUseCase: io.github.landwarderer.futon.download.domain.usecase.AddUnreadToQueueUseCase, + private val workManager: androidx.work.WorkManager, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val localStorageChanges: SharedFlow, ) : BaseViewModel() { @@ -211,6 +214,7 @@ abstract class ChaptersPagesViewModel( fun download(chaptersIds: Set?, allowMeteredNetwork: Boolean) { launchJob(Dispatchers.IO) { val manga = requireManga() + android.util.Log.d("ChaptersPagesVM", "Manual download start for manga: ${manga.title}, chapters: ${chaptersIds?.size ?: "all"}") val task = DownloadTask( mangaId = manga.id, isPaused = false, @@ -219,21 +223,54 @@ abstract class ChaptersPagesViewModel( destination = null, format = null, allowMeteredNetwork = allowMeteredNetwork, + requiresCharging = false, ) downloadScheduler.schedule(setOf(manga to task)) onDownloadStarted.call(Unit) } } + fun scheduleDownload( + chaptersIds: Set?, + wifiOnly: Boolean, + chargingOnly: Boolean, + offPeakOnly: Boolean + ) { + launchJob(Dispatchers.IO) { + val manga = requireManga() + android.util.Log.d("ChaptersPagesVM", "Scheduling download for manga: ${manga.title}, chapters: ${chaptersIds?.size ?: "unread"}") + if (chaptersIds == null) { + addUnreadToQueueUseCase( + manga = manga, + wifiOnly = wifiOnly, + chargingOnly = chargingOnly, + offPeakOnly = offPeakOnly + ) + } else { + downloadQueueRepository.addToQueue( + manga = manga, + chaptersIds = chaptersIds.toLongArray(), + wifiOnly = wifiOnly, + chargingOnly = chargingOnly, + offPeakOnly = offPeakOnly + ) + } + onDownloadStarted.call(Unit) + } + } + fun deleteLocal() { val m = mangaDetails.value?.local?.manga if (m == null) { errorEvent.call(FileNotFoundException()) return } + val isActuallyLocal = mangaDetails.value?.isLocal == true launchLoadingJob(Dispatchers.IO) { deleteLocalMangaUseCase(m) - onMangaRemoved.call(m) + if (isActuallyLocal) { + onMangaRemoved.call(m) + } } } @@ -245,9 +282,14 @@ abstract class ChaptersPagesViewModel( } protected open suspend fun onDownloadComplete(downloadedManga: LocalManga?) { - downloadedManga ?: return mangaDetails.update { - interactor.updateLocal(it, downloadedManga) + if (downloadedManga == null) { + it?.copy(localManga = null) + } else if (it?.id == downloadedManga.manga.id) { + interactor.updateLocal(it, downloadedManga) + } else { + it + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/data/dao/DownloadQueueDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/data/dao/DownloadQueueDao.kt new file mode 100644 index 0000000000..37ab01428b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/data/dao/DownloadQueueDao.kt @@ -0,0 +1,45 @@ +package io.github.landwarderer.futon.download.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.github.landwarderer.futon.download.data.entity.DownloadQueueEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface DownloadQueueDao { + + @Query("SELECT * FROM download_queue ORDER BY priority ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM download_queue ORDER BY priority ASC") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: DownloadQueueEntity): Long + + @Update + suspend fun update(entity: DownloadQueueEntity) + + @Query("DELETE FROM download_queue WHERE id = :id") + suspend fun delete(id: Long) + + @Query("DELETE FROM download_queue") + suspend fun deleteAll() + + @Query("SELECT MAX(priority) FROM download_queue") + suspend fun getMaxPriority(): Int? + + @Transaction + suspend fun reorder(ids: List) { + ids.forEachIndexed { index, id -> + updatePriority(id, index) + } + } + + @Query("UPDATE download_queue SET priority = :priority WHERE id = :id") + suspend fun updatePriority(id: Long, priority: Int) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/data/entity/DownloadQueueEntity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/data/entity/DownloadQueueEntity.kt new file mode 100644 index 0000000000..5f501048a0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/data/entity/DownloadQueueEntity.kt @@ -0,0 +1,76 @@ +package io.github.landwarderer.futon.download.data.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import io.github.landwarderer.futon.core.db.TABLE_DOWNLOAD_QUEUE +import io.github.landwarderer.futon.core.db.entity.MangaEntity + +@Entity( + tableName = TABLE_DOWNLOAD_QUEUE, + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["manga_id"]), + Index(value = ["priority"]), + ], +) +data class DownloadQueueEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Long = 0, + @ColumnInfo(name = "manga_id") + val mangaId: Long, + @ColumnInfo(name = "chapters_ids") + val chaptersIds: LongArray, + @ColumnInfo(name = "priority") + val priority: Int, + @ColumnInfo(name = "created_at") + val createdAt: Long = System.currentTimeMillis(), + @ColumnInfo(name = "wifi_only") + val wifiOnly: Boolean = true, + @ColumnInfo(name = "charging_only") + val charging_only: Boolean = false, + @ColumnInfo(name = "off_peak_only") + val offPeakOnly: Boolean = false, + @ColumnInfo(name = "is_paused") + val isPaused: Boolean = false, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DownloadQueueEntity + + if (id != other.id) return false + if (mangaId != other.mangaId) return false + if (!chaptersIds.contentEquals(other.chaptersIds)) return false + if (priority != other.priority) return false + if (createdAt != other.createdAt) return false + if (wifiOnly != other.wifiOnly) return false + if (charging_only != other.charging_only) return false + if (offPeakOnly != other.offPeakOnly) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + mangaId.hashCode() + result = 31 * result + chaptersIds.contentHashCode() + result = 31 * result + priority + result = 31 * result + createdAt.hashCode() + result = 31 * result + wifiOnly.hashCode() + result = 31 * result + charging_only.hashCode() + result = 31 * result + offPeakOnly.hashCode() + return result + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/data/repository/DownloadQueueRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/data/repository/DownloadQueueRepository.kt new file mode 100644 index 0000000000..cab05265c1 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/data/repository/DownloadQueueRepository.kt @@ -0,0 +1,80 @@ +package io.github.landwarderer.futon.download.data.repository + +import android.util.Log +import androidx.work.WorkManager +import io.github.landwarderer.futon.core.db.dao.MangaDao +import io.github.landwarderer.futon.core.db.entity.toEntity +import io.github.landwarderer.futon.download.data.dao.DownloadQueueDao +import io.github.landwarderer.futon.download.data.entity.DownloadQueueEntity +import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker +import kotlinx.coroutines.flow.Flow +import org.koitharu.kotatsu.parsers.model.Manga +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DownloadQueueRepository @Inject constructor( + private val downloadQueueDao: DownloadQueueDao, + private val mangaDao: MangaDao, + private val workManager: WorkManager, +) { + fun observeQueue(): Flow> = downloadQueueDao.observeAll() + + suspend fun getQueue(): List = downloadQueueDao.getAll() + + suspend fun addToQueue( + manga: Manga, + chaptersIds: LongArray, + wifiOnly: Boolean = true, + chargingOnly: Boolean = false, + offPeakOnly: Boolean = false, + isPaused: Boolean = false + ) { + Log.d("DownloadQueue", "Adding ${chaptersIds.size} chapters to queue for ${manga.title}. WifiOnly: $wifiOnly, ChargingOnly: $chargingOnly, OffPeakOnly: $offPeakOnly, Paused: $isPaused") + val currentQueue = downloadQueueDao.getAll() + val existingForManga = currentQueue.filter { it.mangaId == manga.id } + val newChapters = chaptersIds.filter { id -> + existingForManga.none { it.chaptersIds.contains(id) } + }.toLongArray() + + if (newChapters.isEmpty()) { + Log.d("DownloadQueue", "All requested chapters are already in queue for ${manga.title}") + return + } + + mangaDao.upsert(manga.toEntity()) + val maxPriority = currentQueue.maxOfOrNull { it.priority } ?: -1 + val entity = DownloadQueueEntity( + mangaId = manga.id, + chaptersIds = newChapters, + priority = maxPriority + 1, + wifiOnly = wifiOnly, + charging_only = chargingOnly, + offPeakOnly = offPeakOnly, + isPaused = isPaused + ) + downloadQueueDao.insert(entity) + Log.d("DownloadQueue", "Successfully added ${newChapters.size} chapters to queue for ${manga.title}. Enqueuing DownloadSchedulerWorker with 500ms delay.") + DownloadSchedulerWorker.enqueue(workManager, delay = 500) + } + + suspend fun updatePaused(id: Long, isPaused: Boolean) { + val entity = downloadQueueDao.getAll().find { it.id == id } ?: return + downloadQueueDao.update(entity.copy(isPaused = isPaused)) + if (!isPaused) { + DownloadSchedulerWorker.enqueue(workManager, force = true) + } + } + + suspend fun removeFromQueue(id: Long) { + downloadQueueDao.delete(id) + } + + suspend fun updateQueueOrder(ids: List) { + downloadQueueDao.reorder(ids) + } + + suspend fun clearQueue() { + downloadQueueDao.deleteAll() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/domain/usecase/AddUnreadToQueueUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/domain/usecase/AddUnreadToQueueUseCase.kt new file mode 100644 index 0000000000..d2a1e09ff9 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/domain/usecase/AddUnreadToQueueUseCase.kt @@ -0,0 +1,43 @@ +package io.github.landwarderer.futon.download.domain.usecase + +import android.util.Log +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository +import io.github.landwarderer.futon.mihon.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.model.Manga +import javax.inject.Inject + +class AddUnreadToQueueUseCase @Inject constructor( + private val db: MangaDatabase, + private val downloadQueueRepository: DownloadQueueRepository, +) { + suspend operator fun invoke(manga: Manga, wifiOnly: Boolean, chargingOnly: Boolean, offPeakOnly: Boolean) { + runCatchingCancellable { + val history = db.getHistoryDao().find(manga.id) + val lastChapterId = history?.chapterId ?: -1L + + val chapters = db.getChaptersDao().findAll(manga.id) + val lastChapterIndex = chapters.find { it.chapterId == lastChapterId }?.index ?: -1 + + val unreadChaptersIds = chapters + .filter { it.index > lastChapterIndex } + .map { it.chapterId } + .toLongArray() + + Log.d("AddUnreadToQueue", "Found ${unreadChaptersIds.size} unread chapters for ${manga.title}") + + if (unreadChaptersIds.isNotEmpty()) { + downloadQueueRepository.addToQueue( + manga = manga, + chaptersIds = unreadChaptersIds, + wifiOnly = wifiOnly, + chargingOnly = chargingOnly, + offPeakOnly = offPeakOnly + ) + } + }.onFailure { + it.printStackTraceDebug() + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/domain/usecase/QueueAllUnreadFromFavoritesUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/domain/usecase/QueueAllUnreadFromFavoritesUseCase.kt new file mode 100644 index 0000000000..08a3f28705 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/domain/usecase/QueueAllUnreadFromFavoritesUseCase.kt @@ -0,0 +1,32 @@ +package io.github.landwarderer.futon.download.domain.usecase + +import androidx.work.WorkManager +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker +import io.github.landwarderer.futon.favourites.data.toManga +import io.github.landwarderer.futon.mihon.parsers.util.runCatchingCancellable +import javax.inject.Inject + +class QueueAllUnreadFromFavoritesUseCase @Inject constructor( + private val db: MangaDatabase, + private val addUnreadToQueueUseCase: AddUnreadToQueueUseCase, + private val workManager: WorkManager, +) { + suspend operator fun invoke(wifiOnly: Boolean, chargingOnly: Boolean, offPeakOnly: Boolean) { + runCatchingCancellable { + val favorites = db.getFavouritesDao().findAll() + favorites.forEach { favorite -> + addUnreadToQueueUseCase( + manga = favorite.toManga(), + wifiOnly = wifiOnly, + chargingOnly = chargingOnly, + offPeakOnly = offPeakOnly, + ) + } + DownloadSchedulerWorker.enqueue(workManager) + }.onFailure { + it.printStackTraceDebug() + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueActivity.kt new file mode 100644 index 0000000000..96c65ff6a2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueActivity.kt @@ -0,0 +1,113 @@ +package io.github.landwarderer.futon.download.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.activity.viewModels +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.util.ext.observe +import io.github.landwarderer.futon.databinding.ActivityDownloadQueueBinding + +@AndroidEntryPoint +class DownloadQueueActivity : BaseActivity() { + + private val viewModel by viewModels() + private lateinit var adapter: DownloadQueueAdapter + private lateinit var itemTouchHelper: ItemTouchHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityDownloadQueueBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + setTitle(R.string.download_queue) + + adapter = DownloadQueueAdapter( + onRemove = { item -> viewModel.removeFromQueue(item.entity.id) }, + onPauseToggle = { item -> viewModel.updatePaused(item.entity.id, !item.entity.isPaused) }, + onStartDrag = { viewHolder -> itemTouchHelper.startDrag(viewHolder) } + ) + + viewBinding.recyclerView.adapter = adapter + + itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0 + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + val list = adapter.currentList.toMutableList() + val item = list.removeAt(fromPos) + list.add(toPos, item) + adapter.submitList(list) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewModel.reorderQueue(adapter.currentList.map { it.entity.id }) + } + }) + itemTouchHelper.attachToRecyclerView(viewBinding.recyclerView) + + viewModel.queue.observe(this, adapter::submitList) + + viewBinding.fabStart.setOnClickListener { + viewModel.triggerScheduler() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.opt_download_queue, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_clear_queue -> { + viewModel.clearQueue() + true + } + R.id.action_queue_favorites -> { + viewModel.queueAllUnreadFromFavorites() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val fabMargin = resources.getDimensionPixelSize(R.dimen.fab_margin_bottom_padding) + + viewBinding.recyclerView.updatePadding( + left = bars.left, + right = bars.right, + bottom = bars.bottom + fabMargin, + ) + viewBinding.fabStart.apply { + val params = layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin = bars.bottom + resources.getDimensionPixelSize(R.dimen.fab_margin_bottom_padding) + layoutParams = params + } + viewBinding.appbar.updatePadding( + left = bars.left, + right = bars.right, + top = bars.top, + ) + return insets + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueAdapter.kt new file mode 100644 index 0000000000..a43974e381 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueAdapter.kt @@ -0,0 +1,57 @@ +package io.github.landwarderer.futon.download.ui + +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.databinding.ItemDownloadQueueBinding + +class DownloadQueueAdapter( + private val onRemove: (DownloadQueueItem) -> Unit, + private val onPauseToggle: (DownloadQueueItem) -> Unit, + private val onStartDrag: (RecyclerView.ViewHolder) -> Unit, +) : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemDownloadQueueBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class ViewHolder(private val binding: ItemDownloadQueueBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: DownloadQueueItem) { + binding.textViewTitle.text = item.manga?.title ?: "Unknown" + binding.textViewDetails.text = "${item.entity.chaptersIds.size} chapters" + + binding.imageViewCover.setImageAsync(item.manga?.coverUrl, item.manga) + + binding.buttonRemove.setOnClickListener { onRemove(item) } + binding.buttonPause.setOnClickListener { onPauseToggle(item) } + binding.buttonPause.setImageResource( + if (item.entity.isPaused) R.drawable.ic_action_resume else R.drawable.ic_action_pause + ) + binding.imageViewHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + onStartDrag(this) + } + false + } + } + } + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DownloadQueueItem, newItem: DownloadQueueItem): Boolean { + return oldItem.entity.id == newItem.entity.id + } + + override fun areContentsTheSame(oldItem: DownloadQueueItem, newItem: DownloadQueueItem): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueViewModel.kt new file mode 100644 index 0000000000..947e33a436 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/DownloadQueueViewModel.kt @@ -0,0 +1,97 @@ +package io.github.landwarderer.futon.download.ui + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.TriStateOption +import io.github.landwarderer.futon.core.ui.BaseViewModel +import io.github.landwarderer.futon.download.data.entity.DownloadQueueEntity +import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository +import io.github.landwarderer.futon.download.domain.usecase.QueueAllUnreadFromFavoritesUseCase +import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker +import io.github.landwarderer.futon.local.domain.EnforceStorageQuotaUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import javax.inject.Inject + +@HiltViewModel +class DownloadQueueViewModel @Inject constructor( + private val downloadQueueRepository: DownloadQueueRepository, + private val mangaDataRepository: MangaDataRepository, + private val queueAllUnreadFromFavoritesUseCase: QueueAllUnreadFromFavoritesUseCase, + private val enforceStorageQuotaUseCase: EnforceStorageQuotaUseCase, + private val settings: AppSettings, + private val workManager: androidx.work.WorkManager, +) : BaseViewModel() { + + val storageUsage = MutableStateFlow(null) + + val queue: StateFlow> = downloadQueueRepository.observeQueue() + .map { entities -> + entities.map { entity -> + val manga = mangaDataRepository.findMangaById(entity.mangaId, withChapters = false) + DownloadQueueItem(entity, manga) + } + } + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, emptyList()) + + init { + refreshStorageUsage() + } + + fun refreshStorageUsage() { + launchJob(Dispatchers.IO) { + storageUsage.value = enforceStorageQuotaUseCase.getUsage() + } + } + + fun queueAllUnreadFromFavorites() { + launchJob(Dispatchers.IO) { + val wifiOnly = settings.allowDownloadOnMeteredNetwork == TriStateOption.DISABLED + queueAllUnreadFromFavoritesUseCase( + wifiOnly = wifiOnly, + chargingOnly = settings.isDownloadOnlyOnChargingEnabled, + offPeakOnly = settings.isDownloadOffPeakEnabled + ) + } + } + + fun removeFromQueue(id: Long) { + launchJob(Dispatchers.IO) { + downloadQueueRepository.removeFromQueue(id) + } + } + + fun updatePaused(id: Long, isPaused: Boolean) { + launchJob(Dispatchers.IO) { + downloadQueueRepository.updatePaused(id, isPaused) + } + } + + fun reorderQueue(ids: List) { + launchJob(Dispatchers.IO) { + downloadQueueRepository.updateQueueOrder(ids) + } + } + + fun clearQueue() { + launchJob(Dispatchers.IO) { + downloadQueueRepository.clearQueue() + } + } + + fun triggerScheduler() { + DownloadSchedulerWorker.enqueue(workManager, force = true) + } +} + +data class DownloadQueueItem( + val entity: DownloadQueueEntity, + val manga: org.koitharu.kotatsu.parsers.model.Manga?, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/dialog/DownloadDialogViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/dialog/DownloadDialogViewModel.kt index ed867b9706..f127ebca7a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/dialog/DownloadDialogViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/dialog/DownloadDialogViewModel.kt @@ -17,28 +17,30 @@ import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.core.util.ext.call import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.require +import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository import io.github.landwarderer.futon.download.ui.worker.DownloadTask import io.github.landwarderer.futon.download.ui.worker.DownloadWorker import io.github.landwarderer.futon.history.data.HistoryRepository import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.data.LocalStorageManager -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.parsers.util.sizeOrZero -import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import io.github.landwarderer.futon.settings.storage.DirectoryModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.sizeOrZero +import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import javax.inject.Inject @HiltViewModel class DownloadDialogViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val scheduler: DownloadWorker.Scheduler, + private val downloadQueueRepository: DownloadQueueRepository, private val localStorageManager: LocalStorageManager, private val localMangaRepository: LocalMangaRepository, private val mangaRepositoryFactory: MangaRepository.Factory, @@ -91,19 +93,41 @@ class DownloadDialogViewModel @Inject constructor( allowMetered: Boolean, ) { launchLoadingJob(Dispatchers.IO) { + val requiresCharging = settings.isDownloadOnlyOnChargingEnabled val tasks = mangaDetails.get().map { m -> val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" } - m to DownloadTask( - mangaId = m.id, - isPaused = !startNow, - isSilent = false, - chaptersIds = chaptersMacro.getChaptersIds(m.id, chapters)?.toLongArray(), - destination = destination?.file, - format = format, - allowMeteredNetwork = allowMetered, - ) + val chaptersIds = chaptersMacro.getChaptersIds(m.id, chapters)?.toLongArray() ?: longArrayOf() + m to chaptersIds + } + + if (startNow) { + android.util.Log.d("DownloadDialog", "Starting downloads immediately for ${tasks.size} manga") + val downloadTasks = tasks.map { (m, chaptersIds) -> + m to DownloadTask( + mangaId = m.id, + isPaused = false, + isSilent = false, + chaptersIds = chaptersIds, + destination = destination?.file, + format = format, + allowMeteredNetwork = allowMetered, + requiresCharging = requiresCharging, + ) + } + scheduler.schedule(downloadTasks) + } else { + android.util.Log.d("DownloadDialog", "Adding ${tasks.size} manga to download queue as paused") + tasks.forEach { (m, chaptersIds) -> + downloadQueueRepository.addToQueue( + manga = m, + chaptersIds = chaptersIds, + wifiOnly = !allowMetered, + chargingOnly = requiresCharging, + offPeakOnly = settings.isDownloadOffPeakEnabled, + isPaused = true + ) + } } - scheduler.schedule(tasks) onScheduled.call(startNow) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsActivity.kt index 67c79b2a30..440ce18b65 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsActivity.kt @@ -9,6 +9,7 @@ import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint @@ -19,6 +20,7 @@ import io.github.landwarderer.futon.core.ui.list.ListSelectionController import io.github.landwarderer.futon.core.ui.list.RecyclerScrollKeeper import io.github.landwarderer.futon.core.ui.util.MenuInvalidator import io.github.landwarderer.futon.core.ui.util.ReversibleActionObserver +import io.github.landwarderer.futon.core.util.FileSize import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.core.util.ext.observeEvent import io.github.landwarderer.futon.databinding.ActivityDownloadsBinding @@ -66,6 +68,35 @@ class DownloadsActivity : BaseActivity(), viewModel.hasActiveWorks.observe(this, menuInvalidator) viewModel.hasPausedWorks.observe(this, menuInvalidator) viewModel.hasCancellableWorks.observe(this, menuInvalidator) + + viewModel.storageUsage.observe(this) { usage -> + if (usage != null && usage.totalBytes > 0) { + viewBinding.cardStorage.isVisible = true + val progress = (usage.currentBytes * 100 / usage.totalBytes).toInt() + viewBinding.progressStorage.progress = progress + val isOverQuota = usage.currentBytes >= usage.totalBytes + if (viewBinding.cardQuotaReached.isVisible != isOverQuota) { + viewBinding.cardQuotaReached.isVisible = isOverQuota + } + if (progress >= 90) { + viewBinding.progressStorage.setIndicatorColor(getColor(R.color.common_red)) + } else { + viewBinding.progressStorage.setIndicatorColor(getColor(R.color.blue_primary)) + } + val currentStr = FileSize.BYTES.format(this, usage.currentBytes) + val totalStr = FileSize.BYTES.format(this, usage.totalBytes) + viewBinding.textViewStorageDetails.text = getString(R.string.memory_usage_pattern, currentStr, totalStr) + } else { + viewBinding.cardStorage.isVisible = false + viewBinding.cardQuotaReached.isVisible = false + } + } + viewModel.refreshStorageUsage() + } + + override fun onResume() { + super.onResume() + viewModel.refreshStorageUsage() } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { @@ -73,7 +104,10 @@ class DownloadsActivity : BaseActivity(), viewBinding.recyclerView.updatePadding( left = bars.left, right = bars.right, - bottom = bars.bottom, + bottom = bars.bottom + resources.getDimensionPixelSize(R.dimen.list_spacing_large), + ) + viewBinding.cardStorage.updatePadding( + bottom = bars.bottom ) viewBinding.appbar.updatePadding( left = bars.left, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsMenuProvider.kt index b2a139c891..1bf8f06fad 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsMenuProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsMenuProvider.kt @@ -24,6 +24,7 @@ class DownloadsMenuProvider( R.id.action_resume -> viewModel.resumeAll() R.id.action_cancel_all -> confirmCancelAll() R.id.action_remove_completed -> confirmRemoveCompleted() + R.id.action_queue -> activity.router.openDownloadQueue() R.id.action_settings -> activity.router.openDownloadsSetting() else -> return false } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt index 2257792e99..f558a5bb9f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt @@ -8,19 +8,6 @@ import androidx.collection.set import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.parser.MangaRepository @@ -40,7 +27,21 @@ import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.data.LocalStorageChanges +import io.github.landwarderer.futon.local.domain.EnforceStorageQuotaUseCase import io.github.landwarderer.futon.local.domain.model.LocalManga +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable @@ -55,8 +56,11 @@ class DownloadsViewModel @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, private val localMangaRepository: LocalMangaRepository, + private val enforceStorageQuotaUseCase: EnforceStorageQuotaUseCase, ) : BaseViewModel() { + val storageUsage = MutableStateFlow(null) + private val mangaCache = LongSparseArray() private val cacheMutex = Mutex() private val expanded = MutableStateFlow(emptySet()) @@ -182,6 +186,12 @@ class DownloadsViewModel @Inject constructor( } } + fun refreshStorageUsage() { + launchJob(Dispatchers.IO) { + storageUsage.value = enforceStorageQuotaUseCase.getUsage() + } + } + fun snapshot(ids: LongSet): Collection { return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty() } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt new file mode 100644 index 0000000000..4112919aef --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt @@ -0,0 +1,302 @@ +package io.github.landwarderer.futon.download.ui.worker + +import android.content.Context +import android.net.ConnectivityManager +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.util.ext.awaitWorkInfosByTag +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Calendar +import java.util.concurrent.TimeUnit + +@HiltWorker +class DownloadSchedulerWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val downloadQueueRepository: DownloadQueueRepository, + private val mangaDataRepository: MangaDataRepository, + private val settings: AppSettings, + private val workManager: WorkManager, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + Log.d("DownloadScheduler", "DownloadSchedulerWorker starting") + val force = inputData.getBoolean(KEY_FORCE, false) + return runCatchingCancellable { + val activeWorks = workManager.awaitWorkInfosByTag(DownloadWorker.TAG) + .filter { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.BLOCKED } + + Log.d("DownloadScheduler", "Active works: ${activeWorks.map { it.id to it.state }}") + + if (activeWorks.any { it.state == WorkInfo.State.RUNNING }) { + Log.d("DownloadScheduler", "A download is already running, scheduling check in 30s") + scheduleNextCheck(30, TimeUnit.SECONDS) + return@runCatchingCancellable Result.success() + } + + val queue = downloadQueueRepository.getQueue() + Log.d("DownloadScheduler", "Queue size: ${queue.size}, force=$force") + if (queue.isEmpty()) { + return@runCatchingCancellable Result.success() + } + + val nextItem = queue.firstOrNull { + if (it.isPaused && !force) return@firstOrNull false + if (force) return@firstOrNull true + + val requiresOffPeak = it.offPeakOnly + val requiresCharging = it.charging_only + val requiresWifi = it.wifiOnly + + val canRunOffPeak = !requiresOffPeak || isOffPeakTime() + val canRunCharging = !requiresCharging || isCharging() + val canRunWifi = !requiresWifi || !isMetered() + + Log.d("DownloadScheduler", "Checking item ${it.id} for manga ${it.mangaId}: " + + "offPeak(req=$requiresOffPeak, ok=$canRunOffPeak), " + + "charging(req=$requiresCharging, ok=$canRunCharging), " + + "wifi(req=$requiresWifi, ok=$canRunWifi)") + + canRunOffPeak && canRunCharging && canRunWifi + } + + Log.d("DownloadScheduler", "Next item: ${nextItem?.id}") + + if (nextItem == null) { + scheduleAlarmCheck() + return@runCatchingCancellable Result.success() + } + + val manga = mangaDataRepository.findMangaById(nextItem.mangaId, withChapters = true) + if (manga == null) { + downloadQueueRepository.removeFromQueue(nextItem.id) + return@runCatchingCancellable Result.retry() + } + + val requiresCharging = if (force) false else (nextItem.charging_only || settings.isDownloadOnlyOnChargingEnabled) + val task = DownloadTask( + mangaId = nextItem.mangaId, + isPaused = false, + isSilent = true, + chaptersIds = nextItem.chaptersIds, + destination = null, // Use default + format = null, // Use default + allowMeteredNetwork = force || !nextItem.wifiOnly, + requiresCharging = requiresCharging, + ) + + val downloadRequest = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (!force && nextItem.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresCharging(requiresCharging) + .build() + ) + .addTag(DownloadWorker.TAG) + .addTag(TAG_QUEUED_DOWNLOAD + nextItem.id) + .setInputData(task.toData()) + .build() + + Log.d("DownloadScheduler", "Enqueuing download for manga ${nextItem.mangaId}") + workManager.enqueueUniqueWork( + "download_${nextItem.mangaId}", + ExistingWorkPolicy.KEEP, + downloadRequest + ) + + downloadQueueRepository.removeFromQueue(nextItem.id) + + // Schedule next check to process the rest of the queue + scheduleNextCheck() + + Result.success() + }.getOrElse { + it.printStackTraceDebug("DownloadSchedulerWorker") + Result.retry() + } + } + + private fun isOffPeakTime(): Boolean { + return isOffPeakTime(settings) + } + + private fun isCharging(): Boolean { + val intent = applicationContext.registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED)) + val status = intent?.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1) ?: -1 + return status == android.os.BatteryManager.BATTERY_STATUS_CHARGING || status == android.os.BatteryManager.BATTERY_STATUS_FULL + } + + private fun isMetered(): Boolean { + val cm = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return cm.isActiveNetworkMetered + } + + private fun scheduleNextCheck(delay: Long = 10, unit: TimeUnit = TimeUnit.SECONDS) { + enqueue(workManager, unit.toMillis(delay)) + } + + private suspend fun scheduleAlarmCheck() { + val queue = downloadQueueRepository.getQueue() + if (queue.isEmpty()) return + + val needsCharging = queue.any { it.charging_only || settings.isDownloadOnlyOnChargingEnabled } + val needsOffPeak = queue.any { it.offPeakOnly || settings.isDownloadOffPeakEnabled } + val needsWifi = queue.any { it.wifiOnly } + + if (needsOffPeak) { + val delayInSeconds = calculateSecondsUntilOffPeak() + Log.d("DownloadScheduler", "Scheduling alarm check for off-peak in $delayInSeconds seconds") + if (delayInSeconds > 0) { + scheduleNextCheck(delayInSeconds, TimeUnit.SECONDS) + } + } + + if (needsCharging && !isCharging()) { + Log.d("DownloadScheduler", "Scheduling alarm check for charging") + val request = OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiresCharging(true).build()) + .addTag(TAG_SCHEDULER + "_charging") + .build() + + workManager.enqueueUniqueWork( + WORK_NAME_SCHEDULER + "_charging", + ExistingWorkPolicy.REPLACE, + request + ) + } + + if (needsWifi && isMetered()) { + Log.d("DownloadScheduler", "Scheduling alarm check for Wi-Fi") + val request = OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()) + .addTag(TAG_SCHEDULER + "_wifi") + .build() + + workManager.enqueueUniqueWork( + WORK_NAME_SCHEDULER + "_wifi", + ExistingWorkPolicy.REPLACE, + request + ) + } + } + + private fun calculateSecondsUntilOffPeak(): Long { + if (!settings.isDownloadOffPeakEnabled) return 0 + val now = Calendar.getInstance() + val currentHour = now.get(Calendar.HOUR_OF_DAY) + val currentMinute = now.get(Calendar.MINUTE) + val currentSecond = now.get(Calendar.SECOND) + val currentTimeInSeconds = (currentHour * 60 + currentMinute) * 60 + currentSecond + + val startParts = settings.downloadOffPeakStart.split(":") + if (startParts.size != 2) return 15 * 60 + + val startSeconds = ((startParts[0].toIntOrNull() ?: 0) * 60 + (startParts[1].toIntOrNull() ?: 0)) * 60 + + var diff = startSeconds - currentTimeInSeconds + if (diff <= 0) { + diff += 24 * 60 * 60 + } + return diff.toLong() + } + + companion object { + const val TAG_SCHEDULER = "download_scheduler" + const val TAG_QUEUED_DOWNLOAD = "queued_download_" + const val WORK_NAME_SCHEDULER = "download_scheduler_periodic" + const val KEY_FORCE = "force" + + fun enqueue(workManager: WorkManager, delay: Long = 0, force: Boolean = false) { + Log.d("DownloadScheduler", "Enqueuing scheduler with delay: $delay ms, force=$force") + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .setInputData(androidx.work.Data.Builder().putBoolean(KEY_FORCE, force).build()) + .addTag(TAG_SCHEDULER) + .build() + workManager.enqueueUniqueWork( + WORK_NAME_SCHEDULER, + ExistingWorkPolicy.REPLACE, + request + ) + } + + fun scheduleAlarm(workManager: WorkManager, settings: AppSettings) { + if (!settings.isDownloadOffPeakEnabled || isOffPeakTime(settings)) { + enqueue(workManager) + return + } + + val delayInSeconds = calculateSecondsUntilOffPeak(settings) + Log.d("DownloadScheduler", "Scheduling alarm check in $delayInSeconds seconds") + + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayInSeconds, TimeUnit.SECONDS) + .addTag(TAG_SCHEDULER) + .build() + + workManager.enqueueUniqueWork( + WORK_NAME_SCHEDULER, + ExistingWorkPolicy.REPLACE, + request + ) + } + + private fun isOffPeakTime(settings: AppSettings): Boolean { + if (!settings.isDownloadOffPeakEnabled) return true + val now = Calendar.getInstance() + val currentHour = now.get(Calendar.HOUR_OF_DAY) + val currentMinute = now.get(Calendar.MINUTE) + val currentTimeInMinutes = currentHour * 60 + currentMinute + + val startParts = settings.downloadOffPeakStart.split(":") + val endParts = settings.downloadOffPeakEnd.split(":") + + if (startParts.size != 2 || endParts.size != 2) return true + + val startMinutes = (startParts[0].toIntOrNull() ?: 0) * 60 + (startParts[1].toIntOrNull() ?: 0) + val endMinutes = (endParts[0].toIntOrNull() ?: 0) * 60 + (endParts[1].toIntOrNull() ?: 0) + + val isOffPeak = if (startMinutes < endMinutes) { + currentTimeInMinutes in startMinutes until endMinutes + } else { + currentTimeInMinutes !in endMinutes.., private val slowdownDispatcher: DownloadSlowdownDispatcher, private val imageProxyInterceptor: ImageProxyInterceptor, @@ -119,6 +124,7 @@ class DownloadWorker @AssistedInject constructor( private val task = DownloadTask(params.inputData) private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent) private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val bandwidthLimiter = BandwidthLimiter { settings.downloadBandwidthLimit * 1024 } @Volatile private var lastPublishedState: DownloadState? = null @@ -129,8 +135,12 @@ class DownloadWorker @AssistedInject constructor( private val notificationThrottler = Throttler(400) override suspend fun doWork(): Result { + Log.d("DownloadWorker", "Starting work for mangaId: ${task.mangaId}, taskId: $id") setForeground(getForegroundInfo()) - val manga = mangaDataRepository.findMangaById(task.mangaId, withChapters = true) ?: return Result.failure() + val manga = mangaDataRepository.findMangaById(task.mangaId, withChapters = true) ?: run { + Log.e("DownloadWorker", "Manga not found: ${task.mangaId}") + return Result.failure() + } publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it }) val downloadedIds = getDoneChapters(manga) return try { @@ -141,8 +151,10 @@ class DownloadWorker @AssistedInject constructor( withContext(pausingHandle) { downloadMangaImpl(manga, task, downloadedIds) } + Log.d("DownloadWorker", "Work finished successfully for manga: ${manga.title}") Result.success(currentState.toWorkData()) - } catch (_: CancellationException) { + } catch (e: CancellationException) { + Log.w("DownloadWorker", "Work cancelled for manga: ${manga.title}") withContext(NonCancellable) { val notification = notificationFactory.create(currentState.copy(isStopped = true)) notificationManager.notify(id.hashCode(), notification) @@ -151,6 +163,7 @@ class DownloadWorker @AssistedInject constructor( currentState.copy(eta = -1L, isStuck = false).toWorkData(), ) } catch (e: Exception) { + Log.e("DownloadWorker", "Work failed for manga: ${manga.title}", e) e.printStackTraceDebug("DownloadWorker::doWork") Result.failure( currentState.copy( @@ -162,6 +175,8 @@ class DownloadWorker @AssistedInject constructor( ) } finally { notificationManager.cancel(id.hashCode()) + Log.d("DownloadWorker", "Triggering scheduler from finally block") + DownloadSchedulerWorker.enqueue(WorkManager.getInstance(applicationContext), delay = 1000) } } @@ -271,9 +286,26 @@ class DownloadWorker @AssistedInject constructor( ) } if (output.flushChapter(chapter.value)) { + Log.d("DownloadWorker", "Flushed chapter ${chapter.value.title} for ${manga.title}") runCatchingCancellable { localStorageChanges.emit(LocalMangaParser(output.rootFile).getManga(withDetails = false)) }.onFailure(Throwable::printStackTraceDebug) + + runCatchingCancellable { + if (settings.isAutoDownloadNextChapterEnabled) { + Log.d("DownloadWorker", "Triggering smart cleanup for ${manga.title}") + val deletedCount = deleteReadChaptersUseCase(manga, oldestOnly = true, ignoreFavorite = true) + Log.d("DownloadWorker", "Smart cleanup deleted $deletedCount chapters for ${manga.title}") + } + if (settings.downloadStorageQuota > 0) { + Log.d("DownloadWorker", "Enforcing storage quota") + if (!enforceStorageQuotaUseCase()) { + Log.w("DownloadWorker", "Storage quota reached and cannot be enforced further. Pausing.") + applicationContext.sendBroadcast(PausingReceiver.getPauseIntent(applicationContext, id)) + return@runCatchingCancellable + } + } + }.onFailure(Throwable::printStackTraceDebug) } publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) } @@ -386,7 +418,7 @@ class DownloadWorker @AssistedInject constructor( try { cr.openSource(uri).use { input -> file.sink(append = false).buffer().use { - it.writeAllCancellable(input) + it.writeAllCancellable(input, bandwidthLimiter) } } } catch (e: Exception) { @@ -407,7 +439,7 @@ class DownloadWorker @AssistedInject constructor( ext = MimeTypes.getExtension(body.contentType()?.toMimeType()) ) file.sink(append = false).buffer().use { - it.writeAllCancellable(body.source()) + it.writeAllCancellable(body.source(), bandwidthLimiter) } } } catch (e: Exception) { @@ -485,9 +517,13 @@ class DownloadWorker @AssistedInject constructor( class Scheduler @Inject constructor( @ApplicationContext private val context: Context, private val mangaDataRepository: MangaDataRepository, - private val workManager: WorkManager, + val workManager: WorkManager, ) { + fun triggerScheduler() { + DownloadSchedulerWorker.enqueue(workManager) + } + fun observeWorks(): Flow> = workManager .getWorkInfosByTagFlow(TAG) @@ -544,17 +580,22 @@ class DownloadWorker @AssistedInject constructor( } suspend fun updateConstraints(allowMeteredNetwork: Boolean) { - val constraints = createConstraints(allowMeteredNetwork) val works = workManager.awaitWorkInfosByTag(TAG) for (work in works) { if (work.state.isFinished) { continue } + val task = getTask(work.id) ?: continue + val constraints = createConstraints(allowMeteredNetwork, task.requiresCharging) val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG) .setId(work.id) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { + if (!task.requiresCharging) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } .build() workManager.awaitUpdateWork(request) } @@ -567,23 +608,28 @@ class DownloadWorker @AssistedInject constructor( val requests = tasks.map { (manga, task) -> mangaDataRepository.storeManga(manga, replaceExisting = true) OneTimeWorkRequestBuilder() - .setConstraints(createConstraints(task.allowMeteredNetwork)) + .setConstraints(createConstraints(task.allowMeteredNetwork, task.requiresCharging)) .addTag(TAG) .keepResultsForAtLeast(30, TimeUnit.DAYS) .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) .setInputData(task.toData()) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { + if (!task.requiresCharging) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } .build() } workManager.enqueue(requests).await() } - private fun createConstraints(allowMeteredNetwork: Boolean) = Constraints.Builder() + private fun createConstraints(allowMeteredNetwork: Boolean, requiresCharging: Boolean) = Constraints.Builder() .setRequiredNetworkType(if (allowMeteredNetwork) NetworkType.CONNECTED else NetworkType.UNMETERED) + .setRequiresCharging(requiresCharging) .build() } - private companion object { + companion object { const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_PAGES_PARALLELISM = 4 diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt index bc518f9fd5..ac699ba666 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt @@ -3,15 +3,6 @@ package io.github.landwarderer.futon.explore.ui import androidx.collection.LongSet import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.MangaSourceInfo import io.github.landwarderer.futon.core.os.AppShortcutManager @@ -33,10 +24,19 @@ import io.github.landwarderer.futon.list.ui.model.ListHeader import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState import io.github.landwarderer.futon.list.ui.model.MangaCompactListModel +import io.github.landwarderer.futon.suggestions.domain.SuggestionRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import io.github.landwarderer.futon.suggestions.domain.SuggestionRepository import javax.inject.Inject @HiltViewModel @@ -196,7 +196,7 @@ class ExploreViewModel @Inject constructor( } } - private fun List.toRecommendationList() = map { manga -> + private fun List.toRecommendationList() = skipBlacklistedTags().map { manga -> MangaCompactListModel( manga = manga, override = null, @@ -205,6 +205,16 @@ class ExploreViewModel @Inject constructor( ) } + private fun List.skipBlacklistedTags(): List { + val blacklist = settings.tagsBlacklist + if (blacklist.isEmpty()) { + return this + } + return filterNot { manga -> + manga.tags.any { it.title.lowercase() in blacklist } + } + } + companion object { private const val TIP_SUGGESTIONS = "suggestions" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/history/domain/HistoryUpdateUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/domain/HistoryUpdateUseCase.kt index 01a6d97343..386bd234dd 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/domain/HistoryUpdateUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/domain/HistoryUpdateUseCase.kt @@ -1,22 +1,41 @@ package io.github.landwarderer.futon.history.domain +import android.util.Log +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.db.entity.ChapterEntity +import io.github.landwarderer.futon.core.parser.MangaRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.TriStateOption import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.processLifecycleScope +import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository +import io.github.landwarderer.futon.download.ui.worker.DownloadWorker import io.github.landwarderer.futon.history.data.HistoryRepository -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import io.github.landwarderer.futon.local.data.LocalMangaRepository +import io.github.landwarderer.futon.local.domain.DeleteReadChaptersUseCase import io.github.landwarderer.futon.reader.ui.ReaderState import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class HistoryUpdateUseCase @Inject constructor( private val historyRepository: HistoryRepository, + private val settings: AppSettings, + private val db: MangaDatabase, + private val downloadQueueRepository: DownloadQueueRepository, + private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, + private val localMangaRepository: LocalMangaRepository, + private val downloadScheduler: DownloadWorker.Scheduler, + private val mangaRepositoryFactory: MangaRepository.Factory, ) { + private var lastCheckedChapterId: Long = -1L + suspend operator fun invoke(manga: Manga, readerState: ReaderState, percent: Float) { historyRepository.addOrUpdate( manga = manga, @@ -26,6 +45,77 @@ class HistoryUpdateUseCase @Inject constructor( percent = percent, force = false, ) + if (settings.isAutoDownloadNextChapterEnabled && lastCheckedChapterId != readerState.chapterId && percent > 0.9f) { + Log.d("SmartDownloads", "Threshold met (percent=$percent), triggering check for ${manga.title}") + lastCheckedChapterId = readerState.chapterId + autoDownloadNext(manga, readerState.chapterId) + } + } + + private suspend fun autoDownloadNext(manga: Manga, currentChapterId: Long) { + runCatchingCancellable { + Log.d("SmartDownloads", "Checking auto-download for manga: ${manga.title}, chapter: $currentChapterId") + + // Ensure chapters are loaded in DB + if (manga.chapters.isNullOrEmpty()) { + Log.d("SmartDownloads", "Chapters missing in manga object, refreshing from repository") + val repo = mangaRepositoryFactory.create(manga.source) + val details = repo.getDetails(manga) + db.getChaptersDao().replaceAll(manga.id, details.chapters.orEmpty().withIndex().map { (index, chapter) -> + ChapterEntity( + mangaId = manga.id, + chapterId = chapter.id, + title = chapter.title.orEmpty(), + branch = chapter.branch, + index = index, + number = chapter.number, + volume = chapter.volume, + url = chapter.url, + scanlator = chapter.scanlator, + uploadDate = chapter.uploadDate, + source = chapter.source.name, + ) + }) + } + + val chapters = db.getChaptersDao().findAll(manga.id) + val currentChapter = chapters.find { it.chapterId == currentChapterId } ?: return@runCatchingCancellable + val branch = currentChapter.branch + + val localManga = localMangaRepository.findSavedManga(manga, withDetails = true) + val downloadedChapterIds = localManga?.manga?.chapters?.map { it.id }?.toSet() ?: emptySet() + + val nextChapter = chapters + .filter { it.branch == branch } + .let { branchChapters -> + val currentIndexInBranch = branchChapters.indexOf(currentChapter) + if (currentIndexInBranch != -1 && currentIndexInBranch < branchChapters.size - 1) { + branchChapters.subList(currentIndexInBranch + 1, branchChapters.size) + .find { it.chapterId !in downloadedChapterIds } + } else { + null + } + } + + if (nextChapter == null) { + Log.d("SmartDownloads", "No next chapter found to download for ${manga.title}") + return@runCatchingCancellable + } + + Log.d("SmartDownloads", "Adding next chapter to queue: ${nextChapter.title} (ID: ${nextChapter.chapterId})") + // Smart Downloads: Next chapter is added to queue, oldest read chapter will be deleted when a chapter download finishes + val wifiOnly = settings.allowDownloadOnMeteredNetwork == TriStateOption.DISABLED + downloadQueueRepository.addToQueue( + manga = manga, + chaptersIds = longArrayOf(nextChapter.chapterId), + wifiOnly = wifiOnly, + chargingOnly = settings.isDownloadOnlyOnChargingEnabled, + offPeakOnly = settings.isDownloadOffPeakEnabled, + isPaused = false + ) + }.onFailure { + it.printStackTraceDebug("HistoryUpdateUseCase::autoDownloadNext") + } } fun invokeAsync( diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListViewModel.kt index 44de787083..5b3ac46bba 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListViewModel.kt @@ -1,17 +1,6 @@ package io.github.landwarderer.futon.list.ui import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings @@ -23,9 +12,20 @@ import io.github.landwarderer.futon.core.ui.util.ReversibleAction import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.list.domain.ListFilterOption import io.github.landwarderer.futon.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga import io.github.landwarderer.futon.local.data.LocalStorageChanges import io.github.landwarderer.futon.local.domain.model.LocalManga +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.Manga abstract class MangaListViewModel( private val settings: AppSettings, @@ -54,6 +54,16 @@ abstract class MangaListViewModel( filterNot { it.isNsfw() } } else { this + }.filterBlacklistedTags() + + protected fun List.filterBlacklistedTags(): List { + val blacklist = settings.tagsBlacklist + if (blacklist.isEmpty()) { + return this + } + return filterNot { manga -> + manga.tags.any { it.title.lowercase() in blacklist } + } } protected fun Flow>.combineWithSettings(): Flow> = combine( diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalMangaRepository.kt index a12631e0c3..b5a2f7ff97 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalMangaRepository.kt @@ -18,6 +18,14 @@ import io.github.landwarderer.futon.local.data.output.LocalMangaOutput import io.github.landwarderer.futon.local.data.output.LocalMangaUtil import io.github.landwarderer.futon.local.domain.MangaLock import io.github.landwarderer.futon.local.domain.model.LocalManga +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -30,14 +38,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.toCollection -import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible import java.io.File import java.util.EnumSet import javax.inject.Inject @@ -251,9 +251,9 @@ class LocalMangaRepository @Inject constructor( } } - private suspend fun getRawList(): ArrayList = getRawListAsFlow().toCollection(ArrayList()) + suspend fun getRawList(): ArrayList = getRawListAsFlow().toCollection(ArrayList()) - private suspend fun getAllFiles() = storageManager.getReadableDirs() + suspend fun getAllFiles() = storageManager.getReadableDirs() .asSequence() .flatMap { dir -> dir.withChildren { children -> children.filterNot { it.isHidden || it.shouldSkip() }.toList() } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalStorageManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalStorageManager.kt index 8cbe219246..1294a5186e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalStorageManager.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/LocalStorageManager.kt @@ -13,10 +13,6 @@ import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.core.net.toFile import dagger.Reusable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import okhttp3.Cache import io.github.landwarderer.futon.core.LocalizedAppContext import io.github.landwarderer.futon.core.exceptions.NonFileUriException import io.github.landwarderer.futon.core.prefs.AppSettings @@ -27,6 +23,11 @@ import io.github.landwarderer.futon.core.util.ext.isReadable import io.github.landwarderer.futon.core.util.ext.isWriteable import io.github.landwarderer.futon.core.util.ext.resolveFile import io.github.landwarderer.futon.core.util.ext.takeIfWriteable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.Cache import org.koitharu.kotatsu.parsers.util.mapToSet import java.io.File import javax.inject.Inject @@ -93,6 +94,13 @@ class LocalStorageManager @Inject constructor( getAvailableStorageDirs() } + @WorkerThread + fun getTotalBytesUsedByDownloads(): Long { + return runBlocking { + getConfiguredStorageDirs().sumOf { it.computeSize() } + } + } + suspend fun resolveUri(uri: Uri): File = runInterruptible(Dispatchers.IO) { if (uri.isFileUri()) { uri.toFile() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt index 31b6e433a0..6605c95a0f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt @@ -10,10 +10,10 @@ import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.data.input.LocalMangaParser import io.github.landwarderer.futon.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import javax.inject.Inject import javax.inject.Provider @@ -41,14 +41,23 @@ class LocalMangaIndex @Inject constructor( } suspend fun update() = mutex.withLock { - db.withTransaction { - val dao = db.getLocalMangaIndexDao() - dao.clear() - localMangaRepositoryProvider.get() - .getRawListAsFlow() - .collect { upsert(it) } + println("LocalMangaIndex: Starting update") + runCatchingCancellable { + db.withTransaction { + val dao = db.getLocalMangaIndexDao() + dao.clear() + localMangaRepositoryProvider.get() + .getRawListAsFlow() + .collect { + println("LocalMangaIndex: Found manga ${it.manga.title} at ${it.file.path}") + upsert(it) + } + } + currentVersion = VERSION + println("LocalMangaIndex: Update completed") + }.onFailure { + it.printStackTraceDebug("LocalMangaIndex::update") } - currentVersion = VERSION } suspend fun updateIfRequired() { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/DeleteReadChaptersUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/DeleteReadChaptersUseCase.kt index 59d9d1cde7..c0d7cffdd4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/DeleteReadChaptersUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/DeleteReadChaptersUseCase.kt @@ -1,38 +1,52 @@ package io.github.landwarderer.futon.local.domain +import android.util.Log import io.github.landwarderer.futon.core.model.ids import io.github.landwarderer.futon.core.model.isLocal import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.favourites.domain.FavouritesRepository import io.github.landwarderer.futon.history.data.HistoryRepository import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.findById -import org.koitharu.kotatsu.parsers.util.recoverCatchingCancellable -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.findById +import org.koitharu.kotatsu.parsers.util.recoverCatchingCancellable +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class DeleteReadChaptersUseCase @Inject constructor( private val localMangaRepository: LocalMangaRepository, private val historyRepository: HistoryRepository, private val mangaRepositoryFactory: MangaRepository.Factory, + private val favouritesRepository: FavouritesRepository, ) { - suspend operator fun invoke(manga: Manga): Int { + suspend operator fun invoke(manga: Manga, oldestOnly: Boolean = false, ignoreFavorite: Boolean = false): Int { + if (!ignoreFavorite && favouritesRepository.isFavorite(manga.id)) { + Log.d("DeleteReadChapters", "Skipping deletion for favorite manga: ${manga.title}") + return 0 + } val localManga = if (manga.isLocal) { LocalManga(manga) } else { - checkNotNull(localMangaRepository.findSavedManga(manga)) { "Cannot find local manga" } + localMangaRepository.findSavedManga(manga) ?: run { + Log.d("DeleteReadChapters", "Local manga not found for: ${manga.title}") + return 0 + } } - val task = getDeletionTask(localManga) ?: return 0 + val task = getDeletionTask(localManga, oldestOnly) ?: run { + Log.d("DeleteReadChapters", "No chapters to delete for: ${manga.title}") + return 0 + } + Log.d("DeleteReadChapters", "Deleting ${task.chaptersIds.size} chapters for: ${manga.title}") localMangaRepository.deleteChapters(task.manga.manga, task.chaptersIds) return task.chaptersIds.size } @@ -44,6 +58,9 @@ class DeleteReadChaptersUseCase @Inject constructor( } return channelFlow { for (manga in list) { + if (favouritesRepository.isFavorite(manga.id)) { + continue + } launch(Dispatchers.IO) { val task = runCatchingCancellable { getDeletionTask(LocalManga(manga)) @@ -65,22 +82,45 @@ class DeleteReadChaptersUseCase @Inject constructor( }.fold(0) { acc, x -> acc + x } } - private suspend fun getDeletionTask(manga: LocalManga): DeletionTask? { - val history = historyRepository.getOne(manga.manga) ?: return null - val chapters = getAllChapters(manga) - if (chapters.isEmpty()) { + private suspend fun getDeletionTask(manga: LocalManga, oldestOnly: Boolean = false): DeletionTask? { + val history = historyRepository.getOne(manga.manga) ?: run { + Log.d("DeleteReadChapters", "No history found for ${manga.manga.title}") return null } - val branch = (chapters.findById(history.chapterId) ?: return null).branch - val filteredChapters = chapters.filter { x -> x.branch == branch }.takeWhile { it.id != history.chapterId } - return if (filteredChapters.isEmpty()) { - null - } else { - DeletionTask( - manga = manga, - chaptersIds = filteredChapters.ids(), - ) + val allChapters = getAllChapters(manga).sortedWith(compareBy({ it.volume }, { it.number })) // Ensure oldest first + if (allChapters.isEmpty()) { + Log.d("DeleteReadChapters", "No chapters list found for ${manga.manga.title}") + return null } + val historyChapter = allChapters.findById(history.chapterId) ?: run { + Log.d("DeleteReadChapters", "History chapter ${history.chapterId} not found in all chapters for ${manga.manga.title}") + return null + } + val branch = historyChapter.branch + val readChapters = allChapters.filter { x -> x.branch == branch }.takeWhile { it.id != history.chapterId } + + if (readChapters.isEmpty()) { + Log.d("DeleteReadChapters", "No read chapters before ${history.chapterId} (number: ${historyChapter.number}) in branch $branch for ${manga.manga.title}") + return null + } + + // Only consider chapters that are actually downloaded + val downloadedChapters = localMangaRepository.getDetails(manga.manga).chapters.orEmpty() + val downloadedIds = downloadedChapters.ids() + + val toDelete = readChapters.filter { it.id in downloadedIds } + + if (toDelete.isEmpty()) { + Log.d("DeleteReadChapters", "All ${readChapters.size} read chapters in branch $branch are already deleted for ${manga.manga.title}") + return null + } + + Log.d("DeleteReadChapters", "Found ${toDelete.size} read and downloaded chapters for ${manga.manga.title}. oldestOnly=$oldestOnly. oldest chapter number: ${toDelete.first().number}") + + return DeletionTask( + manga = manga, + chaptersIds = if (oldestOnly) setOf(toDelete.first().id) else toDelete.ids(), + ) } private suspend fun getAllChapters(manga: LocalManga): List = runCatchingCancellable { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/EnforceStorageQuotaUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/EnforceStorageQuotaUseCase.kt new file mode 100644 index 0000000000..1867c5255e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/domain/EnforceStorageQuotaUseCase.kt @@ -0,0 +1,63 @@ +package io.github.landwarderer.futon.local.domain + +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.local.data.LocalMangaRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +class EnforceStorageQuotaUseCase @Inject constructor( + private val settings: AppSettings, + private val localMangaRepository: LocalMangaRepository, + private val db: MangaDatabase, +) { + suspend operator fun invoke(): Boolean = withContext(Dispatchers.IO) { + val quotaMb = settings.downloadStorageQuota + if (quotaMb <= 0) return@withContext true + + val quotaBytes = quotaMb * 1024 * 1024 + val storageDirs = localMangaRepository.getAllFiles().toList() + var currentSize = storageDirs.sumOf { getDirSize(it) } + + if (currentSize <= quotaBytes) return@withContext true + + // Get all downloaded chapters and sort by oldest accessed (approx by history or file time) + // Here we use file last modified time as a proxy for "oldest" + val allChapters = localMangaRepository.getRawList() + .flatMap { manga -> + val dir = localMangaRepository.getOutputDir(manga.manga, null) + dir?.listFiles()?.filter { it.isDirectory }?.map { it to manga } ?: emptyList() + } + .sortedBy { it.first.lastModified() } + + for ((chapterDir, localManga) in allChapters) { + if (currentSize <= quotaBytes) break + + val size = getDirSize(chapterDir) + val chapterId = chapterDir.name.toLongOrNull() ?: continue + + localMangaRepository.deleteChapters(localManga.manga, setOf(chapterId)) + currentSize -= size + } + + currentSize <= quotaBytes + } + + suspend fun getUsage(): StorageUsage? = withContext(Dispatchers.IO) { + val quotaMb = settings.downloadStorageQuota + if (quotaMb <= 0) return@withContext null + val storageDirs = localMangaRepository.getAllFiles().toList() + val currentSize = storageDirs.sumOf { getDirSize(it) } + StorageUsage(currentSize, quotaMb * 1024 * 1024) + } + + private fun getDirSize(dir: File): Long { + if (!dir.exists()) return 0 + if (dir.isFile) return dir.length() + return dir.walkBottomUp().filter { it.isFile }.sumOf { it.length() } + } + + data class StorageUsage(val currentBytes: Long, val totalBytes: Long) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListFragment.kt index deb7a9dc1b..eacb50bb42 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListFragment.kt @@ -75,7 +75,9 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { } override fun onPrimaryButtonClick(tipView: TipView) { - if (!permissionRequestLauncher.tryLaunch(Manifest.permission.READ_EXTERNAL_STORAGE)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + permissionRequestLauncher.launch("") + } else if (!permissionRequestLauncher.tryLaunch(Manifest.permission.READ_EXTERNAL_STORAGE)) { Snackbar.make(tipView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListMenuProvider.kt index b70749305f..4223239725 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListMenuProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalListMenuProvider.kt @@ -34,6 +34,13 @@ class LocalListMenuProvider( true } + R.id.action_rescan -> { + fragment.requireContext().startService( + android.content.Intent(fragment.requireContext(), LocalIndexUpdateService::class.java) + ) + true + } + R.id.action_filter -> { fragment.router.showFilterSheet() true diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalStorageCleanupWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalStorageCleanupWorker.kt index ac68aff7dc..311b104468 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalStorageCleanupWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/ui/LocalStorageCleanupWorker.kt @@ -27,6 +27,7 @@ import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.domain.DeleteReadChaptersUseCase +import io.github.landwarderer.futon.local.domain.EnforceStorageQuotaUseCase import java.util.concurrent.TimeUnit @HiltWorker @@ -37,12 +38,14 @@ class LocalStorageCleanupWorker @AssistedInject constructor( private val localMangaRepository: LocalMangaRepository, private val dataRepository: MangaDataRepository, private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, + private val enforceStorageQuotaUseCase: EnforceStorageQuotaUseCase, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { if (settings.isAutoLocalChaptersCleanupEnabled) { deleteReadChaptersUseCase.invoke() } + enforceStorageQuotaUseCase.invoke() return if (localMangaRepository.cleanup()) { dataRepository.cleanupLocalManga() Result.success() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt index 23bb7efc25..7afeabcb45 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt @@ -7,25 +7,6 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.plus import io.github.landwarderer.futon.R import io.github.landwarderer.futon.bookmarks.domain.Bookmark import io.github.landwarderer.futon.bookmarks.domain.BookmarksRepository @@ -58,12 +39,6 @@ import io.github.landwarderer.futon.list.domain.ReadingProgress.Companion.PROGRE import io.github.landwarderer.futon.local.data.LocalStorageChanges import io.github.landwarderer.futon.local.domain.DeleteLocalMangaUseCase import io.github.landwarderer.futon.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.parsers.util.sizeOrZero import io.github.landwarderer.futon.reader.domain.ChaptersLoader import io.github.landwarderer.futon.reader.domain.DetectReaderModeUseCase import io.github.landwarderer.futon.reader.domain.PageLoader @@ -71,9 +46,32 @@ import io.github.landwarderer.futon.reader.ui.config.ReaderSettings import io.github.landwarderer.futon.reader.ui.pager.ReaderUiState import io.github.landwarderer.futon.scrobbling.discord.ui.DiscordRpc import io.github.landwarderer.futon.stats.domain.StatsCollector -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.sizeOrZero import java.time.Instant import javax.inject.Inject @@ -100,6 +98,9 @@ class ReaderViewModel @Inject constructor( interactor: DetailsInteractor, deleteLocalMangaUseCase: DeleteLocalMangaUseCase, downloadScheduler: DownloadWorker.Scheduler, + downloadQueueRepository: io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository, + addUnreadToQueueUseCase: io.github.landwarderer.futon.download.domain.usecase.AddUnreadToQueueUseCase, + workManager: androidx.work.WorkManager, readerSettingsProducerFactory: ReaderSettings.Producer.Factory, ) : ChaptersPagesViewModel( settings = settings, @@ -107,6 +108,9 @@ class ReaderViewModel @Inject constructor( bookmarksRepository = bookmarksRepository, historyRepository = historyRepository, downloadScheduler = downloadScheduler, + downloadQueueRepository = downloadQueueRepository, + addUnreadToQueueUseCase = addUnreadToQueueUseCase, + workManager = workManager, deleteLocalMangaUseCase = deleteLocalMangaUseCase, localStorageChanges = localStorageChanges, ) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt index 56e2c5813a..25b89bd6e2 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt @@ -9,6 +9,8 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.LocalMangaSource import io.github.landwarderer.futon.core.model.UnknownMangaSource import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.ListMode import io.github.landwarderer.futon.core.ui.BaseViewModel import io.github.landwarderer.futon.core.util.ext.append @@ -23,10 +25,6 @@ import io.github.landwarderer.futon.list.ui.model.EmptyState import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingFooter import io.github.landwarderer.futon.list.ui.model.LoadingState -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.search.domain.SearchKind import io.github.landwarderer.futon.search.domain.SearchV2Helper import kotlinx.coroutines.Dispatchers @@ -43,6 +41,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.Locale import javax.inject.Inject @@ -51,11 +53,13 @@ private const val MAX_PARALLELISM = 4 @HiltViewModel class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val settings: AppSettings, private val mangaListMapper: MangaListMapper, private val searchHelperFactory: SearchV2Helper.Factory, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, + private val dataRepository: MangaDataRepository, ) : BaseViewModel() { val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() @@ -188,11 +192,12 @@ class SearchViewModel @Inject constructor( searchHelper(query, kind) }.fold( onSuccess = { result -> - if (result == null || result.manga.isEmpty()) { + val filteredManga = result?.manga?.filterBlacklistedTags() + if (filteredManga.isNullOrEmpty()) { null } else { val list = mangaListMapper.toListModelList( - manga = result.manga, + manga = filteredManga, mode = ListMode.GRID, ) SearchResultsListModel( @@ -219,11 +224,12 @@ class SearchViewModel @Inject constructor( historyRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> - if (result.isNotEmpty()) { + val filteredManga = result.filterBlacklistedTags() + if (filteredManga.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.history, source = UnknownMangaSource, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + list = mangaListMapper.toListModelList(manga = filteredManga, mode = ListMode.GRID), error = null, listFilter = null, sortOrder = null, @@ -248,12 +254,13 @@ class SearchViewModel @Inject constructor( favouritesRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> - if (result.isNotEmpty()) { + val filteredManga = result.filterBlacklistedTags() + if (filteredManga.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.favourites, source = UnknownMangaSource, list = mangaListMapper.toListModelList( - manga = result, + manga = filteredManga, mode = ListMode.GRID, flags = MangaListMapper.NO_FAVORITE, ), @@ -281,12 +288,13 @@ class SearchViewModel @Inject constructor( searchHelperFactory.create(LocalMangaSource).invoke(query, kind) }.fold( onSuccess = { result -> - if (!result?.manga.isNullOrEmpty()) { + val filteredManga = result?.manga?.filterBlacklistedTags() + if (!filteredManga.isNullOrEmpty()) { SearchResultsListModel( titleResId = 0, source = LocalMangaSource, list = mangaListMapper.toListModelList( - manga = result.manga, + manga = filteredManga, mode = ListMode.GRID, flags = MangaListMapper.NO_SAVED, ), @@ -323,4 +331,26 @@ class SearchViewModel @Inject constructor( } return res } + + private suspend fun List.filterBlacklistedTags(): List { + val blacklist = settings.tagsBlacklist + val filled = map { manga -> + if (manga.tags.isEmpty()) { + val dbManga = dataRepository.findMangaById(manga.id, false) + if (dbManga != null && dbManga.tags.isNotEmpty()) { + manga.copy(tags = dbManga.tags) + } else { + manga + } + } else { + manga + } + } + if (blacklist.isEmpty()) { + return filled + } + return filled.filterNot { manga -> + manga.tags.any { it.title.lowercase() in blacklist } + } + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/DownloadsSettingsFragment.kt index 7362f32b40..84cc6b615e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/DownloadsSettingsFragment.kt @@ -23,13 +23,14 @@ import io.github.landwarderer.futon.core.util.ext.resolveFile import io.github.landwarderer.futon.core.util.ext.setDefaultValueCompat import io.github.landwarderer.futon.core.util.ext.tryLaunch import io.github.landwarderer.futon.core.util.ext.viewLifecycleScope +import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker import io.github.landwarderer.futon.download.ui.worker.DownloadWorker import io.github.landwarderer.futon.local.data.LocalStorageManager -import org.koitharu.kotatsu.parsers.util.names import io.github.landwarderer.futon.settings.utils.DozeHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.util.names import javax.inject.Inject @AndroidEntryPoint @@ -59,6 +60,17 @@ class DownloadsSettingsFragment : entryValues = TriStateOption.entries.names() setDefaultValueCompat(TriStateOption.ASK.name) } + findPreference(AppSettings.KEY_DOWNLOAD_STORAGE_QUOTA)?.setOnPreferenceChangeListener { _, newValue -> + val quotaMb = (newValue as? String)?.toLongOrNull() ?: 0L + if (quotaMb > 0) { + val currentUsage = storageManager.getTotalBytesUsedByDownloads() + if (quotaMb * 1024 * 1024 < currentUsage) { + Snackbar.make(requireView(), R.string.error_quota_too_low, Snackbar.LENGTH_LONG).show() + return@setOnPreferenceChangeListener false + } + } + true + } dozeHelper.updatePreference() } @@ -92,6 +104,12 @@ class DownloadsSettingsFragment : AppSettings.KEY_PAGES_SAVE_DIR -> { findPreference(AppSettings.KEY_PAGES_SAVE_DIR)?.bindPagesDirectory() } + + AppSettings.KEY_DOWNLOAD_OFF_PEAK_ENABLED, + AppSettings.KEY_DOWNLOAD_OFF_PEAK_START, + AppSettings.KEY_DOWNLOAD_OFF_PEAK_END -> { + DownloadSchedulerWorker.enqueue(downloadsScheduler.workManager) + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt index a83b1fbb0c..078cd423cb 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourcesSettingsFragment.kt @@ -88,6 +88,11 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources), true } + AppSettings.KEY_TAGS_BLACKLIST -> { + router.openTagsBlacklist() + true + } + else -> super.onPreferenceTreeClick(preference) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/TagsBlacklistActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/TagsBlacklistActivity.kt new file mode 100644 index 0000000000..5b4fbeeb43 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/TagsBlacklistActivity.kt @@ -0,0 +1,102 @@ +package io.github.landwarderer.futon.settings.sources + +import android.os.Bundle +import android.view.Menu +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.ui.widgets.ChipsView +import io.github.landwarderer.futon.core.util.ext.observe +import io.github.landwarderer.futon.databinding.ActivityTagsBlacklistBinding +import kotlinx.coroutines.flow.combine + +@AndroidEntryPoint +class TagsBlacklistActivity : BaseActivity(), ChipsView.OnChipClickListener { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityTagsBlacklistBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + setTitle(R.string.tags_blacklist) + + viewBinding.chipsBlacklisted.onChipClickListener = this + viewBinding.chipsAvailable.onChipClickListener = this + + combine(viewModel.allTags, viewModel.blacklistedTags, ::Pair).observe(this) { (all, blacklisted) -> + updateChips(all, blacklisted) + } + + viewModel.isLoading.observe(this) { hasLoaded -> + if (hasLoaded) { + viewBinding.progressBar.visibility = View.GONE + } else { + viewBinding.progressBar.visibility = View.VISIBLE + } + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + viewBinding.scrollView.updatePadding( + left = bars.left, + right = bars.right, + bottom = bars.bottom, + ) + viewBinding.appbar.updatePadding( + left = bars.left, + right = bars.right, + top = bars.top, + ) + return WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) + .build() + } + + private fun updateChips(all: List, blacklisted: Set) { + val blacklistedChips = blacklisted.sorted().map { tag -> + ChipsView.ChipModel(title = tag, data = tag, isChecked = true) + } + viewBinding.chipsBlacklisted.setChips(blacklistedChips) + viewBinding.textBlacklistedTitle.isVisible = blacklistedChips.isNotEmpty() + viewBinding.chipsBlacklisted.isVisible = blacklistedChips.isNotEmpty() + + val availableTags = all.filter { it !in blacklisted } + val availableChips = availableTags.map { tag -> + ChipsView.ChipModel(title = tag, data = tag, isChecked = false) + } + viewBinding.chipsAvailable.setChips(availableChips) + } + + override fun onChipClick(chip: Chip, data: Any?) { + val tag = data as? String ?: return + viewModel.toggleTag(tag) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.opt_search, menu) + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + viewModel.performSearch(query) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.performSearch(newText) + return true + } + }) + return true + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/TagsBlacklistViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/TagsBlacklistViewModel.kt new file mode 100644 index 0000000000..9a95df34ea --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/TagsBlacklistViewModel.kt @@ -0,0 +1,78 @@ +package io.github.landwarderer.futon.settings.sources + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.landwarderer.futon.core.network.BaseHttpClient +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.ui.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject + +@HiltViewModel +class TagsBlacklistViewModel @Inject constructor( + private val settings: AppSettings, + @BaseHttpClient private val okHttpClient: OkHttpClient, +) : BaseViewModel() { + + private val _allTags = MutableStateFlow>(emptyList()) + private val _searchQuery = MutableStateFlow(null) + val allTags: StateFlow> = combine(_allTags, _searchQuery) { tags: List, query: String? -> + if (query.isNullOrBlank()) { + tags + } else { + tags.filter { it.contains(query, ignoreCase = true) } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + private val _blacklistedTags = MutableStateFlow(settings.tagsBlacklist) + val blacklistedTags: StateFlow> = _blacklistedTags.asStateFlow() + + init { + fetchTags() + } + + private fun fetchTags() { + launchJob(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("https://raw.githubusercontent.com/AppFuton/filters/refs/heads/main/data/tags.json") + .build() + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val tags = response.body.byteStream().use { stream -> + Json.decodeFromStream>(stream) + } + _allTags.value = tags.sorted() + } + } + } finally { + loadingCounter.decrement() + } + } + } + + fun toggleTag(tag: String) { + val current = _blacklistedTags.value.toMutableSet() + if (current.contains(tag)) { + current.remove(tag) + } else { + current.add(tag) + } + _blacklistedTags.value = current + settings.tagsBlacklist = current + } + + fun performSearch(query: String?) { + _searchQuery.value = query + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/DirectoryConfigAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/DirectoryConfigAD.kt index e07e7eb48d..b74eb93443 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/DirectoryConfigAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/DirectoryConfigAD.kt @@ -48,7 +48,7 @@ fun directoryConfigAD( ContextCompat.getColor(context, R.color.common_red), ), ) { - append(getString(R.string.no_write_permission_to_file)) + append(getString(R.string.directory_inaccessible)) } } if (item.isAppPrivate) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/MangaDirectoriesViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/MangaDirectoriesViewModel.kt index a00182cfa8..4330377f2b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/MangaDirectoriesViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/storage/directories/MangaDirectoriesViewModel.kt @@ -3,16 +3,15 @@ package io.github.landwarderer.futon.settings.storage.directories import android.net.Uri import android.os.StatFs import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.ui.BaseViewModel import io.github.landwarderer.futon.core.util.ext.computeSize import io.github.landwarderer.futon.core.util.ext.isReadable -import io.github.landwarderer.futon.core.util.ext.isWriteable import io.github.landwarderer.futon.local.data.LocalStorageManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow import java.io.File import javax.inject.Inject @@ -87,9 +86,9 @@ class MangaDirectoriesViewModel @Inject constructor( title = storageManager.getDirectoryDisplayName(this, isFullPath = false), path = this, isDefault = isDefault, - isAccessible = isReadable() && isWriteable(), + isAccessible = isReadable(), isAppPrivate = isAppPrivate, size = computeSize(), - available = StatFs(this.absolutePath).availableBytes, + available = try { StatFs(this.absolutePath).availableBytes } catch (e: Exception) { 0L }, ) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TagsAutoCompleteProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TagsAutoCompleteProvider.kt index 8c82f97207..0bebcfbcce 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TagsAutoCompleteProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TagsAutoCompleteProvider.kt @@ -1,7 +1,7 @@ package io.github.landwarderer.futon.settings.utils -import javax.inject.Inject import io.github.landwarderer.futon.core.db.MangaDatabase +import javax.inject.Inject class TagsAutoCompleteProvider @Inject constructor( private val db: MangaDatabase, @@ -11,7 +11,7 @@ class TagsAutoCompleteProvider @Inject constructor( if (query.isEmpty()) { return emptyList() } - val tags = db.getTagsDao().findTags(query = "$query%", limit = 6) + val tags = db.getTagsDao().searchAllTags(query = "$query%", limit = 10) val set = HashSet() val result = ArrayList(tags.size) for (tag in tags) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TimePreference.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TimePreference.kt new file mode 100644 index 0000000000..56562387b8 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/TimePreference.kt @@ -0,0 +1,75 @@ +package io.github.landwarderer.futon.settings.utils + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import androidx.fragment.app.FragmentActivity +import androidx.preference.Preference +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import io.github.landwarderer.futon.core.util.ext.findActivity +import java.util.Locale + +class TimePreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, + defStyleRes: Int = 0, +) : Preference(context, attrs, defStyleAttr, defStyleRes) { + + private var hour: Int = 0 + private var minute: Int = 0 + + init { + isPersistent = true + } + + override fun onGetDefaultValue(a: TypedArray, index: Int): Any? { + return a.getString(index) + } + + override fun onSetInitialValue(defaultValue: Any?) { + val value = getPersistedString(defaultValue as? String ?: "00:00") + decodeTime(value) + updateSummary() + } + + private fun decodeTime(value: String?) { + if (value == null) return + val parts = value.split(":") + if (parts.size == 2) { + hour = parts[0].toIntOrNull() ?: 0 + minute = parts[1].toIntOrNull() ?: 0 + } + } + + private fun updateSummary() { + summary = String.format(Locale.getDefault(), "%02d:%02d", hour, minute) + } + + override fun onClick() { + val activity = context.findActivity() as? FragmentActivity ?: return + showPicker(activity) + } + + private fun showPicker(activity: FragmentActivity) { + val picker = MaterialTimePicker.Builder() + .setTimeFormat(TimeFormat.CLOCK_24H) + .setHour(hour) + .setMinute(minute) + .setTitleText(title) + .build() + + picker.addOnPositiveButtonClickListener { + hour = picker.hour + minute = picker.minute + val value = String.format(Locale.US, "%02d:%02d", hour, minute) + if (callChangeListener(value)) { + persistString(value) + updateSummary() + } + } + + picker.show(activity.supportFragmentManager, "time_picker") + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt index 073e04b2cf..5451b94407 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt @@ -1,12 +1,14 @@ package io.github.landwarderer.futon.settings.work import android.content.SharedPreferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.processLifecycleScope +import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker +import io.github.landwarderer.futon.download.ui.worker.DownloadWorker import io.github.landwarderer.futon.suggestions.ui.SuggestionsWorker import io.github.landwarderer.futon.tracker.work.TrackWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +17,7 @@ class WorkScheduleManager @Inject constructor( private val settings: AppSettings, private val suggestionScheduler: SuggestionsWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler, + private val downloadScheduler: DownloadWorker.Scheduler, ) : SharedPreferences.OnSharedPreferenceChangeListener { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -33,6 +36,12 @@ class WorkScheduleManager @Inject constructor( isEnabled = settings.isSuggestionsEnabled, force = key != AppSettings.KEY_SUGGESTIONS, ) + + AppSettings.KEY_DOWNLOAD_OFF_PEAK_ENABLED, + AppSettings.KEY_DOWNLOAD_OFF_PEAK_START, + AppSettings.KEY_DOWNLOAD_OFF_PEAK_END -> { + DownloadSchedulerWorker.scheduleAlarm(downloadScheduler.workManager, settings) + } } } @@ -41,6 +50,7 @@ class WorkScheduleManager @Inject constructor( processLifecycleScope.launch(Dispatchers.IO) { updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, true) // always force due to adaptive interval updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) + DownloadSchedulerWorker.scheduleAlarm(downloadScheduler.workManager, settings) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagsBlacklist.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagsBlacklist.kt index fd120e2623..15f398aaf3 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagsBlacklist.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagsBlacklist.kt @@ -1,5 +1,6 @@ package io.github.landwarderer.futon.suggestions.domain +import android.util.Log import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.almostEquals @@ -18,6 +19,7 @@ class TagsBlacklist( for (mangaTag in manga.tags) { for (tagTitle in tags) { if (mangaTag.title.almostEquals(tagTitle, threshold)) { + Log.d("TagsBlacklist", "Manga \"${manga.title}\" blacklisted by tag: $tagTitle") return true } } @@ -25,7 +27,11 @@ class TagsBlacklist( return false } - operator fun contains(tag: MangaTag): Boolean = tags.any { - it.almostEquals(tag.title, threshold) + operator fun contains(tag: MangaTag): Boolean = tags.any { tagTitle -> + val matches = tag.title.almostEquals(tagTitle, threshold) + if (matches) { + Log.d("TagsBlacklist", "Tag \"${tag.title}\" is blacklisted (matches: $tagTitle)") + } + matches } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt index 1b9a5a6786..18a0b4b2c7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt @@ -262,6 +262,7 @@ class TrackWorker @AssistedInject constructor( destination = null, format = null, allowMeteredNetwork = settings.allowDownloadOnMeteredNetwork != TriStateOption.DISABLED, + requiresCharging = false, ) downloadSchedulerLazy.get().schedule(setOf(mangaUpdates.manga to task)) } diff --git a/app/src/main/res/layout/activity_download_queue.xml b/app/src/main/res/layout/activity_download_queue.xml new file mode 100644 index 0000000000..6b801f72c6 --- /dev/null +++ b/app/src/main/res/layout/activity_download_queue.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml index 5db1a3dd21..ace409ffae 100644 --- a/app/src/main/res/layout/activity_downloads.xml +++ b/app/src/main/res/layout/activity_downloads.xml @@ -41,4 +41,85 @@ app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" tools:listitem="@layout/item_download" /> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_tags_blacklist.xml b/app/src/main/res/layout/activity_tags_blacklist.xml new file mode 100644 index 0000000000..918e6635bc --- /dev/null +++ b/app/src/main/res/layout/activity_tags_blacklist.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_download_queue.xml b/app/src/main/res/layout/item_download_queue.xml new file mode 100644 index 0000000000..e7de0c4bd3 --- /dev/null +++ b/app/src/main/res/layout/item_download_queue.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/opt_download_queue.xml b/app/src/main/res/menu/opt_download_queue.xml new file mode 100644 index 0000000000..fffe8d7d5c --- /dev/null +++ b/app/src/main/res/menu/opt_download_queue.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/menu/opt_downloads.xml b/app/src/main/res/menu/opt_downloads.xml index c349522f45..2e19b8db63 100644 --- a/app/src/main/res/menu/opt_downloads.xml +++ b/app/src/main/res/menu/opt_downloads.xml @@ -27,6 +27,11 @@ android:title="@string/remove_completed" app:showAsAction="never" /> + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 2ac92cd40c..cb20fdee0e 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -83,4 +83,6 @@ 3dp 92dp + + 16dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13701a44ce..94b206d2c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,6 +98,7 @@ Vibration Favorite categories Remove + Download unread from favorites It\'s kind of empty here… Try to reformulate the query. What you read will be displayed here @@ -304,6 +305,8 @@ Random Are you sure you want to delete the selected favorite categories?\nAll manga in it will be lost and this cannot be undone. Reorder + Download queue + Clear queue Empty Explore Press "Back" again to exit @@ -400,6 +403,8 @@ Cancel all Download only via Wi-Fi Stop downloading when switching to a mobile network + Download only when charging + Items will only be downloaded when the device is charging Suggestion: %s Sometimes show notifications with suggested manga More @@ -925,4 +930,24 @@ Installed Install Uninstall + Blacklisted tags + Exclude manga containing these tags from lists and search results + Download scheduling and limits + Off-peak downloads + Download only during specified hours + Start time + End time + Storage quota (MB) + Limit total download size. 0 for unlimited. Current oldest chapters will be deleted if exceeded. + Smart Downloads + Automatically download next chapters and delete read ones + When downloads can start + When downloads must stop + Rescan + Rescan completed + Rescan started + This directory is inaccessible. Please check permissions or re-add it. + Connect to a power source to start downloads + Storage quota cannot be lower than current usage + Storage quota reached. Downloads paused. diff --git a/app/src/main/res/xml/pref_downloads.xml b/app/src/main/res/xml/pref_downloads.xml index 021cfdf5bf..090319e76e 100644 --- a/app/src/main/res/xml/pref_downloads.xml +++ b/app/src/main/res/xml/pref_downloads.xml @@ -55,4 +55,47 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index cf10f51fa7..8fbcbf070a 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -50,6 +50,11 @@ android:title="@string/incognito_for_nsfw" app:useSimpleSummaryProvider="true" /> + Start 10:00 -> 1 hour = 3600 seconds + assertEquals(3600L, calculateSecondsUntilOffPeakMock(isEnabled = true, start = "10:00", hour = 9, minute = 0, second = 0)) + + // Current time 11:00:00 -> Start 10:00 -> Next day 10:00:00 = 23 hours = 82800 seconds + assertEquals(23 * 3600L, calculateSecondsUntilOffPeakMock(isEnabled = true, start = "10:00", hour = 11, minute = 0, second = 0)) + + // Current time 10:00:00 -> Start 10:00 -> Next day 10:00:00 = 24 hours + assertEquals(24 * 3600L, calculateSecondsUntilOffPeakMock(isEnabled = true, start = "10:00", hour = 10, minute = 0, second = 0)) + } + + // Helper to replicate DownloadSchedulerWorker.isOffPeakTime logic for testing + private fun isOffPeakTimeMock(isEnabled: Boolean, start: String, end: String, hour: Int, minute: Int): Boolean { + if (!isEnabled) return true + val currentTimeInMinutes = hour * 60 + minute + + val startParts = start.split(":") + val endParts = end.split(":") + + val startMinutes = (startParts[0].toIntOrNull() ?: 0) * 60 + (startParts[1].toIntOrNull() ?: 0) + val endMinutes = (endParts[0].toIntOrNull() ?: 0) * 60 + (endParts[1].toIntOrNull() ?: 0) + + return if (startMinutes < endMinutes) { + currentTimeInMinutes in startMinutes until endMinutes + } else { + currentTimeInMinutes >= startMinutes || currentTimeInMinutes < endMinutes + } + } + + // Helper to replicate DownloadSchedulerWorker.calculateSecondsUntilOffPeak logic for testing + private fun calculateSecondsUntilOffPeakMock(isEnabled: Boolean, start: String, hour: Int, minute: Int, second: Int): Long { + if (!isEnabled) return 0 + val currentTimeInSeconds = (hour * 60 + minute) * 60 + second + + val startParts = start.split(":") + val startSeconds = ((startParts[0].toIntOrNull() ?: 0) * 60 + (startParts[1].toIntOrNull() ?: 0)) * 60 + + var diff = startSeconds - currentTimeInSeconds + if (diff <= 0) { + diff += 24 * 60 * 60 + } + return diff.toLong() + } +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadTaskTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadTaskTest.kt new file mode 100644 index 0000000000..341dd64b3a --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadTaskTest.kt @@ -0,0 +1,52 @@ +package io.github.landwarderer.futon.download.ui.worker + +import androidx.work.Data +import io.github.landwarderer.futon.core.prefs.DownloadFormat +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class DownloadTaskTest { + + @Test + fun `test DownloadTask serialization and deserialization`() { + val originalTask = DownloadTask( + mangaId = 123L, + isPaused = true, + isSilent = false, + chaptersIds = longArrayOf(1L, 2L, 3L), + destination = File("/tmp/manga"), + format = DownloadFormat.SINGLE_CBZ, + allowMeteredNetwork = false, + requiresCharging = true + ) + + val data = originalTask.toData() + val restoredTask = DownloadTask(data) + + assertEquals(originalTask.mangaId, restoredTask.mangaId) + assertEquals(originalTask.isPaused, restoredTask.isPaused) + assertEquals(originalTask.isSilent, restoredTask.isSilent) + assertEquals(originalTask.chaptersIds?.toList(), restoredTask.chaptersIds?.toList()) + assertEquals(originalTask.destination, restoredTask.destination) + assertEquals(originalTask.format, restoredTask.format) + assertEquals(originalTask.allowMeteredNetwork, restoredTask.allowMeteredNetwork) + assertEquals(originalTask.requiresCharging, restoredTask.requiresCharging) + assertEquals(originalTask, restoredTask) + } + + @Test + fun `test DownloadTask default values in Data constructor`() { + val emptyData = Data.EMPTY + val task = DownloadTask(emptyData) + + assertEquals(0L, task.mangaId) + assertEquals(false, task.isPaused) + assertEquals(false, task.isSilent) + assertEquals(null, task.chaptersIds) + assertEquals(null, task.destination) + assertEquals(null, task.format) + assertEquals(true, task.allowMeteredNetwork) // Default is true in constructor + assertEquals(false, task.requiresCharging) + } +}