From caf836992453a4b278879758dfdaa87f90203ea9 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:41:39 +0000 Subject: [PATCH] let the user allow downloads on mobile data Downloads asked the platform for an unmetered network and nothing more, so there was no way to run them on mobile. A setting under Downloads now decides, defaulting to Wi-Fi only, and it also governs roaming: the platform permits roaming by default, which would have contradicted the Wi-Fi-only choice. The flag rides the enqueue request like the download quality does, cached in the source repository so the poller does not read the preference per row. --- .../subtracks/data/download/DownloadEngine.kt | 1 + .../data/download/SystemDownloadEngine.kt | 6 +++-- .../subtracks/data/prefs/UserPreferences.kt | 9 +++++++ .../subtracks/data/repo/DownloadRepository.kt | 7 ++++- .../subtracks/data/repo/SourceRepository.kt | 8 ++++++ .../subtracks/ui/settings/SettingsScreen.kt | 17 ++++++++++++ .../ui/settings/SettingsViewModel.kt | 9 +++++++ .../data/repo/DownloadRepositoryTest.kt | 26 ++++++++++++++++++- .../com/subtracks/ui/AppScreenshotTest.kt | 2 ++ 9 files changed, 81 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt index c55c6993..c8c602de 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt @@ -4,6 +4,7 @@ data class EngineRequest( val uri: String, val path: String, val title: String, + val allowMetered: Boolean = false, ) enum class EngineStatus { Pending, Running, Completed, Failed } diff --git a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt index 3fe2b963..1201e9ed 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt @@ -15,8 +15,10 @@ class SystemDownloadEngine( .setTitle(request.title) .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) - // Downloads wait for an unmetered network until the preference for them exists. - .setAllowedOverMetered(false) + // Wi-Fi only unless the preference allows a metered network, and roaming counts as + // one of those; the platform otherwise permits it by default. + .setAllowedOverMetered(request.allowMetered) + .setAllowedOverRoaming(request.allowMetered) return manager().enqueue(download) } diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index a5e9f487..f7fa580d 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -3,6 +3,7 @@ package com.subtracks.data.prefs import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -88,6 +89,14 @@ class UserPreferences( private val downloadQualityKey = stringPreferencesKey("download_quality") + fun downloadOverMetered(): Flow = store.data.map { prefs -> prefs[downloadOverMeteredKey] ?: false } + + suspend fun setDownloadOverMetered(allowed: Boolean) { + store.edit { prefs -> prefs[downloadOverMeteredKey] = allowed } + } + + private val downloadOverMeteredKey = booleanPreferencesKey("download_over_metered") + fun syncConcurrency(): Flow = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) } suspend fun setSyncConcurrency(value: Int) { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index e68ce93f..de7699f0 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -163,7 +163,12 @@ class DownloadRepository( val engineId = try { engine.enqueue( - EngineRequest(uri = url, path = enginePath(row.sourceId, row.songId), title = title), + EngineRequest( + uri = url, + path = enginePath(row.sourceId, row.songId), + title = title, + allowMetered = sourceRepository.downloadsAllowedOverMetered(), + ), ) } catch (e: CancellationException) { throw e diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 61ff61a8..f79b2576 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -52,10 +52,18 @@ class SourceRepository( @Volatile private var downloadQuality = StreamQuality() + @Volatile + private var allowMeteredDownloads = false + + fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads + init { scope.launch { prefs.downloadQuality().collect { downloadQuality = it } } + scope.launch { + prefs.downloadOverMetered().collect { allowMeteredDownloads = it } + } scope.launch { combine( db.sourcesDao().activeSubsonicConfig(), diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index e5cf016a..f15eeff5 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -70,6 +71,7 @@ fun SettingsRoute( val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle() val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle() val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle() + val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle() SettingsScreen( sources = sources, activeSourceId = activeSourceId, @@ -77,12 +79,14 @@ fun SettingsRoute( mobileQuality = mobileQuality, syncConcurrency = syncConcurrency, downloadQuality = downloadQuality, + downloadOverMetered = downloadOverMetered, onSelectSource = viewModel::selectSource, onEditServer = onEditServer, onWifiQualityChange = viewModel::setWifiQuality, onMobileQualityChange = viewModel::setMobileQuality, onSyncConcurrencyChange = viewModel::setSyncConcurrency, onDownloadQualityChange = viewModel::setDownloadQuality, + onDownloadOverMeteredChange = viewModel::setDownloadOverMetered, onAddServer = onAddServer, onOpenDownloads = onOpenDownloads, onBack = onBack, @@ -98,12 +102,14 @@ fun SettingsScreen( mobileQuality: StreamQuality, syncConcurrency: Int, downloadQuality: StreamQuality, + downloadOverMetered: Boolean, onSelectSource: (Long) -> Unit, onEditServer: (Long) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit, onMobileQualityChange: (StreamQuality) -> Unit, onSyncConcurrencyChange: (Int) -> Unit, onDownloadQualityChange: (StreamQuality) -> Unit, + onDownloadOverMeteredChange: (Boolean) -> Unit, onAddServer: () -> Unit, onOpenDownloads: () -> Unit, onBack: () -> Unit, @@ -199,6 +205,17 @@ fun SettingsScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + item { + ListItem( + headlineContent = { Text("Download over mobile data") }, + supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") }, + trailingContent = { + Switch(checked = downloadOverMetered, onCheckedChange = onDownloadOverMeteredChange) + }, + modifier = Modifier.clickable { onDownloadOverMeteredChange(!downloadOverMetered) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } item { ListItem( headlineContent = { Text("Manage downloads") }, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index ea86698a..60fe8d0b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -38,6 +38,11 @@ class SettingsViewModel( .downloadQuality() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality()) + val downloadOverMetered: StateFlow = + userPreferences + .downloadOverMetered() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncConcurrency: StateFlow = userPreferences .syncConcurrency() @@ -59,6 +64,10 @@ class SettingsViewModel( viewModelScope.launch { userPreferences.setDownloadQuality(quality) } } + fun setDownloadOverMetered(allowed: Boolean) { + viewModelScope.launch { userPreferences.setDownloadOverMetered(allowed) } + } + fun setSyncConcurrency(value: Int) { viewModelScope.launch { userPreferences.setSyncConcurrency(value) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index 25d81b52..7105f1be 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -52,6 +52,7 @@ class DownloadRepositoryTest { private lateinit var repository: DownloadRepository private lateinit var artwork: ArtworkStore private lateinit var dir: File + private val prefs = fakeUserPreferences() private val messages = CopyOnWriteArrayList() private val requestedArt = CopyOnWriteArrayList() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList() @@ -73,7 +74,7 @@ class DownloadRepositoryTest { .setDriver(BundledSQLiteDriver()) .build() artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) - sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork) + sources = SourceRepository(db, OkHttpClient(), prefs, artwork) engine = FakeDownloadEngine() dir = File(context.cacheDir, "downloads-${System.nanoTime()}") repository = @@ -956,6 +957,29 @@ class DownloadRepositoryTest { assertNull(repository.localUri("s1")) } + @Test + fun downloadsAreWifiOnlyUntilThePreferenceAllowsMetered() = + runTest { + seedLibrary() + runBlocking { repository.download(1, "s1") } + assertFalse( + engine.requests + .single() + .second.allowMetered, + ) + + prefs.setDownloadOverMetered(true) + await { sources.downloadsAllowedOverMetered() } + + runBlocking { repository.download(1, "s2") } + + assertTrue( + engine.requests + .last() + .second.allowMetered, + ) + } + private fun requestedSongIds(): List = engine.requests.map { it.second.path.substringAfterLast('/') } private fun seedSongs(tracks: IntRange) { diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 4a732bb5..d6c38cb2 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -344,12 +344,14 @@ class AppScreenshotTest { mobileQuality = StreamQuality(192, "mp3"), syncConcurrency = 4, downloadQuality = StreamQuality(128, "opus"), + downloadOverMetered = false, onSelectSource = {}, onEditServer = {}, onWifiQualityChange = {}, onMobileQualityChange = {}, onSyncConcurrencyChange = {}, onDownloadQualityChange = {}, + onDownloadOverMeteredChange = {}, onAddServer = {}, onOpenDownloads = {}, onBack = {},