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.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 02b3ddbb98
commit caf8369924
9 changed files with 81 additions and 4 deletions
@@ -4,6 +4,7 @@ data class EngineRequest(
val uri: String, val uri: String,
val path: String, val path: String,
val title: String, val title: String,
val allowMetered: Boolean = false,
) )
enum class EngineStatus { Pending, Running, Completed, Failed } enum class EngineStatus { Pending, Running, Completed, Failed }
@@ -15,8 +15,10 @@ class SystemDownloadEngine(
.setTitle(request.title) .setTitle(request.title)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path) .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
// Downloads wait for an unmetered network until the preference for them exists. // Wi-Fi only unless the preference allows a metered network, and roaming counts as
.setAllowedOverMetered(false) // one of those; the platform otherwise permits it by default.
.setAllowedOverMetered(request.allowMetered)
.setAllowedOverRoaming(request.allowMetered)
return manager().enqueue(download) return manager().enqueue(download)
} }
@@ -3,6 +3,7 @@ package com.subtracks.data.prefs
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -88,6 +89,14 @@ class UserPreferences(
private val downloadQualityKey = stringPreferencesKey("download_quality") private val downloadQualityKey = stringPreferencesKey("download_quality")
fun downloadOverMetered(): Flow<Boolean> = 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<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) } fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) }
suspend fun setSyncConcurrency(value: Int) { suspend fun setSyncConcurrency(value: Int) {
@@ -163,7 +163,12 @@ class DownloadRepository(
val engineId = val engineId =
try { try {
engine.enqueue( 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) { } catch (e: CancellationException) {
throw e throw e
@@ -52,10 +52,18 @@ class SourceRepository(
@Volatile @Volatile
private var downloadQuality = StreamQuality() private var downloadQuality = StreamQuality()
@Volatile
private var allowMeteredDownloads = false
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
init { init {
scope.launch { scope.launch {
prefs.downloadQuality().collect { downloadQuality = it } prefs.downloadQuality().collect { downloadQuality = it }
} }
scope.launch {
prefs.downloadOverMetered().collect { allowMeteredDownloads = it }
}
scope.launch { scope.launch {
combine( combine(
db.sourcesDao().activeSubsonicConfig(), db.sourcesDao().activeSubsonicConfig(),
@@ -31,6 +31,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -70,6 +71,7 @@ fun SettingsRoute(
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle() val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle() val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle() val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
SettingsScreen( SettingsScreen(
sources = sources, sources = sources,
activeSourceId = activeSourceId, activeSourceId = activeSourceId,
@@ -77,12 +79,14 @@ fun SettingsRoute(
mobileQuality = mobileQuality, mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency, syncConcurrency = syncConcurrency,
downloadQuality = downloadQuality, downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
onSelectSource = viewModel::selectSource, onSelectSource = viewModel::selectSource,
onEditServer = onEditServer, onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality, onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality, onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency, onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onDownloadQualityChange = viewModel::setDownloadQuality, onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onAddServer = onAddServer, onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads, onOpenDownloads = onOpenDownloads,
onBack = onBack, onBack = onBack,
@@ -98,12 +102,14 @@ fun SettingsScreen(
mobileQuality: StreamQuality, mobileQuality: StreamQuality,
syncConcurrency: Int, syncConcurrency: Int,
downloadQuality: StreamQuality, downloadQuality: StreamQuality,
downloadOverMetered: Boolean,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit, onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit, onSyncConcurrencyChange: (Int) -> Unit,
onDownloadQualityChange: (StreamQuality) -> Unit, onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onOpenDownloads: () -> Unit, onOpenDownloads: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@@ -199,6 +205,17 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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 { item {
ListItem( ListItem(
headlineContent = { Text("Manage downloads") }, headlineContent = { Text("Manage downloads") },
@@ -38,6 +38,11 @@ class SettingsViewModel(
.downloadQuality() .downloadQuality()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val downloadOverMetered: StateFlow<Boolean> =
userPreferences
.downloadOverMetered()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncConcurrency: StateFlow<Int> = val syncConcurrency: StateFlow<Int> =
userPreferences userPreferences
.syncConcurrency() .syncConcurrency()
@@ -59,6 +64,10 @@ class SettingsViewModel(
viewModelScope.launch { userPreferences.setDownloadQuality(quality) } viewModelScope.launch { userPreferences.setDownloadQuality(quality) }
} }
fun setDownloadOverMetered(allowed: Boolean) {
viewModelScope.launch { userPreferences.setDownloadOverMetered(allowed) }
}
fun setSyncConcurrency(value: Int) { fun setSyncConcurrency(value: Int) {
viewModelScope.launch { userPreferences.setSyncConcurrency(value) } viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
} }
@@ -52,6 +52,7 @@ class DownloadRepositoryTest {
private lateinit var repository: DownloadRepository private lateinit var repository: DownloadRepository
private lateinit var artwork: ArtworkStore private lateinit var artwork: ArtworkStore
private lateinit var dir: File private lateinit var dir: File
private val prefs = fakeUserPreferences()
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private val requestedArt = CopyOnWriteArrayList<String>() private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
@@ -73,7 +74,7 @@ class DownloadRepositoryTest {
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork) sources = SourceRepository(db, OkHttpClient(), prefs, artwork)
engine = FakeDownloadEngine() engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}") dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
repository = repository =
@@ -956,6 +957,29 @@ class DownloadRepositoryTest {
assertNull(repository.localUri("s1")) 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<String> = engine.requests.map { it.second.path.substringAfterLast('/') } private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) { private fun seedSongs(tracks: IntRange) {
@@ -344,12 +344,14 @@ class AppScreenshotTest {
mobileQuality = StreamQuality(192, "mp3"), mobileQuality = StreamQuality(192, "mp3"),
syncConcurrency = 4, syncConcurrency = 4,
downloadQuality = StreamQuality(128, "opus"), downloadQuality = StreamQuality(128, "opus"),
downloadOverMetered = false,
onSelectSource = {}, onSelectSource = {},
onEditServer = {}, onEditServer = {},
onWifiQualityChange = {}, onWifiQualityChange = {},
onMobileQualityChange = {}, onMobileQualityChange = {},
onSyncConcurrencyChange = {}, onSyncConcurrencyChange = {},
onDownloadQualityChange = {}, onDownloadQualityChange = {},
onDownloadOverMeteredChange = {},
onAddServer = {}, onAddServer = {},
onOpenDownloads = {}, onOpenDownloads = {},
onBack = {}, onBack = {},