diff --git a/app/build.gradle b/app/build.gradle index ee10ac380..ef0b6a892 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -103,6 +103,8 @@ dependencies { //noinspection LifecycleAnnotationProcessorWithJava8 kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1' implementation 'androidx.work:work-runtime-ktx:2.8.1' +// implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' + implementation 'com.google.guava:guava:31.1-android' implementation 'androidx.room:room-runtime:2.5.1' implementation 'androidx.room:room-ktx:2.5.1' diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 6386dff7c..09dd69825 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -238,8 +238,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isDownloadsSlowdownEnabled: Boolean get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false) - val downloadsParallelism: Int - get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2) + val isDownloadsWiFiOnly: Boolean + get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) val isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) @@ -381,8 +381,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SHIKIMORI = "shikimori" const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" - const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" + const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_DOH = "doh" const val KEY_EXIT_CONFIRM = "exit_confirm" diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index 2ebef3182..b9873d5a3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -43,22 +43,22 @@ class DownloadsViewModel @Inject constructor( private val cacheMutex = Mutex() private val works = workScheduler.observeWorks() .mapLatest { it.toDownloadsList() } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val items = works.map { - it.toUiList() + it?.toUiList() ?: listOf(LoadingState) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) val hasPausedWorks = works.map { - it.any { x -> x.canResume } + it?.any { x -> x.canResume } == true }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) val hasActiveWorks = works.map { - it.any { x -> x.canPause } + it?.any { x -> x.canPause } == true }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) val hasCancellableWorks = works.map { - it.any { x -> !x.workState.isFinished } + it?.any { x -> !x.workState.isFinished } == true }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false) fun cancel(id: UUID) { @@ -69,7 +69,7 @@ class DownloadsViewModel @Inject constructor( fun cancel(ids: Set) { launchJob(Dispatchers.Default) { - val snapshot = works.value + val snapshot = works.value ?: return@launchJob for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.cancel(work.id) @@ -85,7 +85,7 @@ class DownloadsViewModel @Inject constructor( } fun pause(ids: Set) { - val snapshot = works.value + val snapshot = works.value ?: return for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.pause(work.id) @@ -94,7 +94,7 @@ class DownloadsViewModel @Inject constructor( } fun pauseAll() { - val snapshot = works.value + val snapshot = works.value ?: return for (work in snapshot) { if (work.canPause) { workScheduler.pause(work.id) @@ -103,7 +103,7 @@ class DownloadsViewModel @Inject constructor( } fun resumeAll() { - val snapshot = works.value + val snapshot = works.value ?: return for (work in snapshot) { if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { workScheduler.resume(work.id) @@ -112,7 +112,7 @@ class DownloadsViewModel @Inject constructor( } fun resume(ids: Set) { - val snapshot = works.value + val snapshot = works.value ?: return for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.resume(work.id) @@ -122,7 +122,7 @@ class DownloadsViewModel @Inject constructor( fun remove(ids: Set) { launchJob(Dispatchers.Default) { - val snapshot = works.value + val snapshot = works.value ?: return@launchJob for (work in snapshot) { if (work.id.mostSignificantBits in ids) { workScheduler.delete(work.id) @@ -138,12 +138,12 @@ class DownloadsViewModel @Inject constructor( } fun snapshot(ids: Set): Collection { - return works.value.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids } + return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty() } - fun allIds(): Set = works.value.mapToSet { + fun allIds(): Set = works.value?.mapToSet { it.id.mostSignificantBits - } + } ?: emptySet() private suspend fun List.toDownloadsList(): List { if (isEmpty()) { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index ee2bb97ff..c33e7400d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -48,6 +48,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.utils.WorkManagerHelper import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.deleteAwait @@ -297,6 +298,7 @@ class DownloadWorker @AssistedInject constructor( class Scheduler @Inject constructor( @ApplicationContext private val context: Context, private val dataRepository: MangaDataRepository, + private val settings: AppSettings, ) { private val workManager: WorkManager @@ -349,13 +351,34 @@ class DownloadWorker @AssistedInject constructor( } suspend fun removeCompleted() { - workManager.pruneWork().await() + val helper = WorkManagerHelper(workManager) + val finishedWorks = helper.getFinishedWorkInfosByTag(TAG) + helper.deleteWorks(finishedWorks.mapToSet { it.id }) + } + + suspend fun updateConstraints() { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + val helper = WorkManagerHelper(workManager) + val works = helper.getWorkInfosByTag(TAG) + for (work in works) { + if (work.state.isFinished) { + continue + } + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setId(work.id) + .build() + helper.updateWork(request) + } } private suspend fun scheduleImpl(data: Collection) { val constraints = Constraints.Builder() .setRequiresStorageNotLow(true) - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .build() val requests = data.map { inputData -> OneTimeWorkRequestBuilder() @@ -364,7 +387,7 @@ class DownloadWorker @AssistedInject constructor( .keepResultsForAtLeast(7, TimeUnit.DAYS) .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) .setInputData(inputData) - .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } workManager.enqueue(requests).await() diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 872caac79..34523393b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -7,17 +7,20 @@ import androidx.preference.ListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.utils.ext.getStorageName +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import java.io.File @@ -35,16 +38,12 @@ class ContentSettingsFragment : @Inject lateinit var contentCache: ContentCache + @Inject + lateinit var downloadsScheduler: DownloadWorker.Scheduler + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_content) findPreference(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled - findPreference(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run { - summary = value.toString() - setOnPreferenceChangeListener { preference, newValue -> - preference.summary = newValue.toString() - true - } - } findPreference(AppSettings.KEY_DOH)?.run { entryValues = arrayOf( DoHProvider.NONE, @@ -87,6 +86,10 @@ class ContentSettingsFragment : bindRemoteSourcesSummary() } + AppSettings.KEY_DOWNLOADS_WIFI -> { + updateDownloadsConstraints() + } + AppSettings.KEY_SSL_BYPASS -> { Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() } @@ -126,4 +129,20 @@ class ContentSettingsFragment : summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) } } + + private fun updateDownloadsConstraints() { + val preference = findPreference(AppSettings.KEY_DOWNLOADS_WIFI) + viewLifecycleScope.launch { + try { + preference?.isEnabled = false + withContext(Dispatchers.Default) { + downloadsScheduler.updateConstraints() + } + } catch (e: Exception) { + e.printStackTraceDebug() + } finally { + preference?.isEnabled = true + } + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt index b2c60f441..9b5187c9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/WorkManagerHelper.kt @@ -1,7 +1,11 @@ package org.koitharu.kotatsu.utils import android.annotation.SuppressLint +import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkRequest +import androidx.work.await import androidx.work.impl.WorkManagerImpl import java.util.UUID import kotlin.coroutines.resume @@ -25,4 +29,35 @@ class WorkManagerHelper( } } } + + suspend fun deleteWorks(ids: Collection) = suspendCoroutine { cont -> + workManagerImpl.workTaskExecutor.executeOnTaskThread { + try { + val db = workManagerImpl.workDatabase + db.runInTransaction { + for (id in ids) { + db.workSpecDao().delete(id.toString()) + } + } + cont.resume(Unit) + } catch (e: Exception) { + cont.resumeWithException(e) + } + } + } + + suspend fun getWorkInfosByTag(tag: String): List { + return workManagerImpl.getWorkInfosByTag(tag).await() + } + + suspend fun getFinishedWorkInfosByTag(tag: String): List { + val query = WorkQuery.Builder.fromTags(listOf(tag)) + .addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED)) + .build() + return workManagerImpl.getWorkInfos(query).await() + } + + suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult { + return workManagerImpl.updateWork(request).await() + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7385cedf1..17c13c6fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -440,4 +440,6 @@ Paused Remove completed Cancel all + Download only via Wi-Fi + Stop downloading when switching to a mobile network diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index fd971b20c..dba25ca15 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -30,26 +30,6 @@ app:useSimpleSummaryProvider="true" tools:isPreferenceVisible="true" /> - - - - - - - + + + + + + + + + diff --git a/app/src/main/res/xml/pref_root.xml b/app/src/main/res/xml/pref_root.xml index 4d3d12ff6..5d3298421 100644 --- a/app/src/main/res/xml/pref_root.xml +++ b/app/src/main/res/xml/pref_root.xml @@ -34,6 +34,11 @@ android:icon="@drawable/ic_feed" android:title="@string/check_for_new_chapters" /> + +