localize transient messages and the download notification

Route user-facing messages produced below the UI through a typed UiMessage
(string-resource id plus format args) instead of pre-built English strings:
showMessage, SyncStatus.Failed, playback errors and download toasts now
resolve in the current locale at the UI edge. Subsonic and page-cap
exceptions carry a UiMessage alongside their developer message, and the
download progress notification is formatted from resources.

Internal invariants and log tags stay plain strings.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent a1e45380cc
commit 3162ac07d5
24 changed files with 165 additions and 61 deletions
@@ -0,0 +1,19 @@
package com.subtracks
import android.content.Context
import android.content.res.Resources
import androidx.annotation.StringRes
data class UiMessage(
@param:StringRes val resId: Int,
val args: List<Any> = emptyList(),
) {
fun resolve(resources: Resources): String = resources.getString(resId, *args.toTypedArray())
fun resolve(context: Context): String = resolve(context.resources)
}
class UiException(
val uiMessage: UiMessage,
message: String = "",
) : Exception(message)
@@ -1,5 +1,7 @@
package com.subtracks.data.download
import android.content.res.Resources
import com.subtracks.R
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.SongDownload
@@ -9,14 +11,19 @@ data class DownloadNotification(
val progress: Float?,
)
fun downloadNotification(rows: Collection<SongDownload>): DownloadNotification? {
fun downloadNotification(
resources: Resources,
rows: Collection<SongDownload>,
): DownloadNotification? {
val active = rows.filter { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Running }
if (active.isEmpty()) return null
val total = active.sumOf { it.total }
val progress = if (total > 0) (active.sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else null
return DownloadNotification(
title = if (active.size == 1) "Downloading 1 song" else "Downloading ${active.size} songs",
text = progress?.let { "${(it * 100).toInt()}%" } ?: "In progress",
title = resources.getQuantityString(R.plurals.download_notification_title, active.size, active.size),
text =
progress?.let { resources.getString(R.string.download_notification_progress, (it * 100).toInt()) }
?: resources.getString(R.string.download_notification_in_progress),
progress = progress,
)
}
@@ -32,7 +32,7 @@ class DownloadNotifier(
createChannel()
scope.launch {
repository.activeDownloads().collect { rows ->
val content = downloadNotification(rows)
val content = downloadNotification(context.resources, rows)
if (content == null) {
manager.cancel(ID)
} else if (manager.areNotificationsEnabled()) {
@@ -1,6 +1,8 @@
package com.subtracks.data.repo
import android.net.Uri
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
@@ -48,7 +50,7 @@ class DownloadRepository(
private val downloadsDir: File,
private val artworkStore: ArtworkStore,
private val artworkFetcher: ArtworkFetcher,
private val showMessage: (String) -> Unit = {},
private val showMessage: (UiMessage) -> Unit = {},
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
) {
@@ -111,11 +113,11 @@ class DownloadRepository(
songId: String,
) = withContext(dispatcher) {
if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage(NOT_ACTIVE)
showMessage(UiMessage(R.string.error_download_inactive_source))
return@withContext
}
if (sourceRepository.downloadUri(sourceId, songId) == null) {
showMessage(NO_ADDRESS)
showMessage(UiMessage(R.string.error_download_no_address))
return@withContext
}
val promoted =
@@ -243,16 +245,16 @@ class DownloadRepository(
refId: String,
) = withContext(dispatcher) {
if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage(NOT_ACTIVE)
showMessage(UiMessage(R.string.error_download_inactive_source))
return@withContext
}
val songIds = songIds(sourceId, list, refId)
if (songIds.isEmpty()) {
showMessage(EMPTY_LIST)
showMessage(UiMessage(R.string.error_download_nothing_left))
return@withContext
}
if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) {
showMessage(NO_ADDRESS)
showMessage(UiMessage(R.string.error_download_no_address))
return@withContext
}
// Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and
@@ -655,10 +657,6 @@ class DownloadRepository(
const val POLL_MS = 1_000L
const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900
const val NO_ADDRESS = "Can't download: the server address is unavailable"
const val EMPTY_LIST = "Nothing left to download from this list"
const val STOPPED = "The download stopped unexpectedly"
const val NOT_ACTIVE = "Can't download from a source that isn't active"
const val ART_TIMEOUT_MS = 60_000L
}
}
@@ -4,6 +4,8 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.albumsDefaultQuery
import com.subtracks.data.db.artistsDefaultQuery
@@ -38,7 +40,7 @@ class LibraryRepository(
private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository,
private val serverActions: ServerActionSink,
private val showMessage: (String) -> Unit = {},
private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
@@ -444,7 +446,7 @@ class LibraryRepository(
throw cancellation
} catch (failure: Exception) {
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
showMessage("Could not update star")
showMessage(UiMessage(R.string.star_update_failed))
Result.failure(failure)
}
}
@@ -1,5 +1,8 @@
package com.subtracks.data.repo
import com.subtracks.R
import com.subtracks.UiException
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
@@ -35,7 +38,7 @@ class SourceRepository(
private val prefs: UserPreferences,
private val artworkStore: ArtworkStore,
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi),
private val showMessage: (String) -> Unit = {},
private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private data class ActiveSource(
@@ -222,7 +225,7 @@ class SourceRepository(
useTokenAuth: Boolean,
): Result<Boolean> =
if (_offline.value) {
Result.failure(IllegalStateException("Offline mode is on"))
Result.failure(UiException(UiMessage(R.string.sync_offline), "Offline mode is on"))
} else {
withContext(Dispatchers.IO) {
var fellBack = false
@@ -262,7 +265,7 @@ class SourceRepository(
private fun disableTokenAuth(sourceId: Long) {
scope.launch {
db.sourcesDao().disableTokenAuth(sourceId)
showMessage("Server does not support token auth; using the password instead")
showMessage(UiMessage(R.string.connection_token_fallback))
}
}
@@ -1,5 +1,7 @@
package com.subtracks.data.source.subsonic
import com.subtracks.R
import com.subtracks.UiMessage
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -9,6 +11,7 @@ import java.security.MessageDigest
open class SubsonicException(
val code: Int,
message: String,
val uiMessage: UiMessage? = null,
) : Exception(message)
class SubsonicClient(
@@ -77,7 +80,7 @@ class SubsonicClient(
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw SubsonicException(-1, "HTTP ${response.code}")
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
}
return body(response.body.byteStream())
}
@@ -1,5 +1,7 @@
package com.subtracks.data.source.subsonic
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
@@ -282,7 +284,13 @@ class SubsonicSource(
return supported
}
private fun pageCapExceeded() = PageCapExceeded("Library is too large to sync: it exceeds ${maxPages.toLong() * PAGE_SIZE} rows")
private fun pageCapExceeded(): PageCapExceeded {
val rows = maxPages.toLong() * PAGE_SIZE
return PageCapExceeded(
"Library is too large to sync: it exceeds $rows rows",
UiMessage(R.string.error_library_too_large, listOf(rows)),
)
}
private fun <T> entityBatches(
method: String,
@@ -336,4 +344,5 @@ class SubsonicSource(
private class PageCapExceeded(
message: String,
) : SubsonicException(-1, message)
uiMessage: UiMessage,
) : SubsonicException(-1, message, uiMessage)
@@ -1,5 +1,7 @@
package com.subtracks.data.source.subsonic
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
@@ -224,6 +226,7 @@ private const val ROOT_TAG = "subsonic-response"
private class XmlFailure(
val code: Int,
message: String,
val uiMessage: UiMessage? = null,
) : RuntimeException(message)
private class StopReading : RuntimeException()
@@ -240,7 +243,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
) {
depth++
if (depth == 1) {
if (qName != ROOT_TAG) throw XmlFailure(-1, "Unexpected response from the server")
if (qName != ROOT_TAG) throw XmlFailure(-1, "Unexpected response from the server", UiMessage(R.string.error_server_unexpected))
if (attributes.getValue("status") == "failed") statusFailed = true
}
if (qName == "error") {
@@ -262,7 +265,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
}
override fun endDocument() {
if (statusFailed) throw XmlFailure(-1, "Unknown error")
if (statusFailed) throw XmlFailure(-1, "Unknown error", UiMessage(R.string.error_server_unknown))
}
protected open fun onElementStart(
@@ -328,12 +331,12 @@ private fun parse(
try {
secureParserFactory().newSAXParser().parse(input, handler)
} catch (failure: XmlFailure) {
throw SubsonicException(failure.code, failure.message ?: "Unknown error")
throw SubsonicException(failure.code, failure.message ?: "Unknown error", failure.uiMessage)
} catch (_: StopReading) {
} catch (failure: SAXException) {
throw SubsonicException(-1, failure.message ?: "Malformed response from the server")
throw SubsonicException(-1, failure.message ?: "Malformed response from the server", UiMessage(R.string.error_server_malformed))
} catch (failure: IOException) {
throw SubsonicException(-1, failure.message ?: "Truncated response from the server")
throw SubsonicException(-1, failure.message ?: "Truncated response from the server", UiMessage(R.string.error_server_truncated))
}
}
@@ -1,9 +1,13 @@
package com.subtracks.data.sync
import android.util.Log
import com.subtracks.R
import com.subtracks.UiException
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.subsonic.SubsonicException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -21,7 +25,7 @@ sealed interface SyncStatus {
data object Success : SyncStatus
data class Failed(
val message: String,
val message: UiMessage,
) : SyncStatus
}
@@ -29,7 +33,7 @@ class SyncManager(
private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository,
private val showMessage: (String) -> Unit = {},
private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val requests = Channel<Unit>(Channel.CONFLATED)
@@ -48,22 +52,37 @@ class SyncManager(
private suspend fun runSync() {
if (sourceRepository.offline.value) {
showMessage("Offline mode is on")
_status.value = SyncStatus.Failed("Offline mode is on")
val offline = UiMessage(R.string.sync_offline)
showMessage(offline)
_status.value = SyncStatus.Failed(offline)
return
}
_status.value = SyncStatus.Running
val result =
try {
val source = sourceRepository.activeMusicSource() ?: error("No server configured")
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
SyncService(db, source).sync()
SyncStatus.Success
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
Log.w(TAG, "Sync failed", failure)
val message = failure.message ?: "unknown error"
showMessage("Sync failed: $message")
val message =
when (failure) {
is NoServerException -> {
UiMessage(R.string.error_no_server)
}
is UiException -> {
failure.uiMessage
}
else -> {
(failure as? SubsonicException)?.uiMessage
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
}
}
showMessage(message)
SyncStatus.Failed(message)
}
queueRepository.invalidateLibraryCache()
@@ -74,3 +93,5 @@ class SyncManager(
const val TAG = "SubtracksSync"
}
}
private class NoServerException : IllegalStateException()
@@ -6,6 +6,7 @@ import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
@@ -55,8 +56,8 @@ fun appModule(
http: OkHttpClient,
) = module {
val mainHandler = Handler(Looper.getMainLooper())
val toast: (String) -> Unit = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
val toast: (UiMessage) -> Unit = { message ->
mainHandler.post { Toast.makeText(context, message.resolve(context), Toast.LENGTH_SHORT).show() }
}
single { createAndroidDatabase(context) }
single { http }
@@ -11,6 +11,8 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
@@ -235,13 +237,13 @@ internal fun queueMediaItem(
return builder.build()
}
internal fun playbackErrorMessage(error: PlaybackException): String =
internal fun playbackErrorMessage(error: PlaybackException): UiMessage =
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
-> "Can't reach the server. Check your connection."
-> UiMessage(R.string.playback_error_network)
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "The server refused to stream this track."
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> UiMessage(R.string.playback_error_refused)
else -> error.errorCodeName
else -> UiMessage(R.string.playback_error, listOf(error.errorCodeName))
}
@@ -1,5 +1,6 @@
package com.subtracks.playback
import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueEntry
@@ -108,7 +109,7 @@ class PlaybackController(
private val queueRepository: QueueRepository,
private val connection: PlayerConnection,
private val downloads: DownloadRepository,
private val showMessage: (String) -> Unit = {},
private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
) {
private val _state = MutableStateFlow(PlaybackState())
@@ -1414,7 +1415,7 @@ class PlaybackController(
refresh()
}
override fun onError(message: String) {
override fun onError(message: UiMessage) {
showMessage(message)
refresh()
}
@@ -1,5 +1,6 @@
package com.subtracks.playback
import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding
interface PlayerHandle {
@@ -75,7 +76,7 @@ interface PlayerHandle {
fun onEvents()
fun onError(message: String) {
fun onError(message: UiMessage) {
}
}
}
@@ -4,6 +4,7 @@ import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.UiException
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
@@ -99,7 +100,10 @@ class AddSourceViewModel(
resources.getString(R.string.connection_ok)
}
},
{ error -> resources.getString(R.string.connection_failed, error.message) },
{ error ->
(error as? UiException)?.uiMessage?.resolve(resources)
?: resources.getString(R.string.connection_failed, error.message)
},
),
isError = result.isFailure,
)