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 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.DownloadStatus
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
@@ -9,14 +11,19 @@ data class DownloadNotification(
val progress: Float?, 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 } val active = rows.filter { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Running }
if (active.isEmpty()) return null if (active.isEmpty()) return null
val total = active.sumOf { it.total } val total = active.sumOf { it.total }
val progress = if (total > 0) (active.sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else null val progress = if (total > 0) (active.sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else null
return DownloadNotification( return DownloadNotification(
title = if (active.size == 1) "Downloading 1 song" else "Downloading ${active.size} songs", title = resources.getQuantityString(R.plurals.download_notification_title, active.size, active.size),
text = progress?.let { "${(it * 100).toInt()}%" } ?: "In progress", text =
progress?.let { resources.getString(R.string.download_notification_progress, (it * 100).toInt()) }
?: resources.getString(R.string.download_notification_in_progress),
progress = progress, progress = progress,
) )
} }
@@ -32,7 +32,7 @@ class DownloadNotifier(
createChannel() createChannel()
scope.launch { scope.launch {
repository.activeDownloads().collect { rows -> repository.activeDownloads().collect { rows ->
val content = downloadNotification(rows) val content = downloadNotification(context.resources, rows)
if (content == null) { if (content == null) {
manager.cancel(ID) manager.cancel(ID)
} else if (manager.areNotificationsEnabled()) { } else if (manager.areNotificationsEnabled()) {
@@ -1,6 +1,8 @@
package com.subtracks.data.repo package com.subtracks.data.repo
import android.net.Uri import android.net.Uri
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkFetcher import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -48,7 +50,7 @@ class DownloadRepository(
private val downloadsDir: File, private val downloadsDir: File,
private val artworkStore: ArtworkStore, private val artworkStore: ArtworkStore,
private val artworkFetcher: ArtworkFetcher, private val artworkFetcher: ArtworkFetcher,
private val showMessage: (String) -> Unit = {}, private val showMessage: (UiMessage) -> Unit = {},
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
) { ) {
@@ -111,11 +113,11 @@ class DownloadRepository(
songId: String, songId: String,
) = withContext(dispatcher) { ) = withContext(dispatcher) {
if (sourceRepository.activeSourceIdOnce() != sourceId) { if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage(NOT_ACTIVE) showMessage(UiMessage(R.string.error_download_inactive_source))
return@withContext return@withContext
} }
if (sourceRepository.downloadUri(sourceId, songId) == null) { if (sourceRepository.downloadUri(sourceId, songId) == null) {
showMessage(NO_ADDRESS) showMessage(UiMessage(R.string.error_download_no_address))
return@withContext return@withContext
} }
val promoted = val promoted =
@@ -243,16 +245,16 @@ class DownloadRepository(
refId: String, refId: String,
) = withContext(dispatcher) { ) = withContext(dispatcher) {
if (sourceRepository.activeSourceIdOnce() != sourceId) { if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage(NOT_ACTIVE) showMessage(UiMessage(R.string.error_download_inactive_source))
return@withContext return@withContext
} }
val songIds = songIds(sourceId, list, refId) val songIds = songIds(sourceId, list, refId)
if (songIds.isEmpty()) { if (songIds.isEmpty()) {
showMessage(EMPTY_LIST) showMessage(UiMessage(R.string.error_download_nothing_left))
return@withContext return@withContext
} }
if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) { if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) {
showMessage(NO_ADDRESS) showMessage(UiMessage(R.string.error_download_no_address))
return@withContext return@withContext
} }
// Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and // 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 POLL_MS = 1_000L
const val IN_FLIGHT_LIMIT = 8 const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900 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 const val ART_TIMEOUT_MS = 60_000L
} }
} }
@@ -4,6 +4,8 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingSource import androidx.paging.PagingSource
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.albumsDefaultQuery import com.subtracks.data.db.albumsDefaultQuery
import com.subtracks.data.db.artistsDefaultQuery import com.subtracks.data.db.artistsDefaultQuery
@@ -38,7 +40,7 @@ class LibraryRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val serverActions: ServerActionSink, private val serverActions: ServerActionSink,
private val showMessage: (String) -> Unit = {}, private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) { ) {
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId() val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
@@ -444,7 +446,7 @@ class LibraryRepository(
throw cancellation throw cancellation
} catch (failure: Exception) { } catch (failure: Exception) {
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) } withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
showMessage("Could not update star") showMessage(UiMessage(R.string.star_update_failed))
Result.failure(failure) Result.failure(failure)
} }
} }
@@ -1,5 +1,8 @@
package com.subtracks.data.repo 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.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
@@ -35,7 +38,7 @@ class SourceRepository(
private val prefs: UserPreferences, private val prefs: UserPreferences,
private val artworkStore: ArtworkStore, private val artworkStore: ArtworkStore,
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi), 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 val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
private data class ActiveSource( private data class ActiveSource(
@@ -222,7 +225,7 @@ class SourceRepository(
useTokenAuth: Boolean, useTokenAuth: Boolean,
): Result<Boolean> = ): Result<Boolean> =
if (_offline.value) { if (_offline.value) {
Result.failure(IllegalStateException("Offline mode is on")) Result.failure(UiException(UiMessage(R.string.sync_offline), "Offline mode is on"))
} else { } else {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var fellBack = false var fellBack = false
@@ -262,7 +265,7 @@ class SourceRepository(
private fun disableTokenAuth(sourceId: Long) { private fun disableTokenAuth(sourceId: Long) {
scope.launch { scope.launch {
db.sourcesDao().disableTokenAuth(sourceId) 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 package com.subtracks.data.source.subsonic
import com.subtracks.R
import com.subtracks.UiMessage
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -9,6 +11,7 @@ import java.security.MessageDigest
open class SubsonicException( open class SubsonicException(
val code: Int, val code: Int,
message: String, message: String,
val uiMessage: UiMessage? = null,
) : Exception(message) ) : Exception(message)
class SubsonicClient( class SubsonicClient(
@@ -77,7 +80,7 @@ class SubsonicClient(
.build() .build()
http.newCall(request).execute().use { response -> http.newCall(request).execute().use { response ->
if (!response.isSuccessful) { 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()) return body(response.body.byteStream())
} }
@@ -1,5 +1,7 @@
package com.subtracks.data.source.subsonic 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.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -282,7 +284,13 @@ class SubsonicSource(
return supported 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( private fun <T> entityBatches(
method: String, method: String,
@@ -336,4 +344,5 @@ class SubsonicSource(
private class PageCapExceeded( private class PageCapExceeded(
message: String, message: String,
) : SubsonicException(-1, message) uiMessage: UiMessage,
) : SubsonicException(-1, message, uiMessage)
@@ -1,5 +1,7 @@
package com.subtracks.data.source.subsonic 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.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -224,6 +226,7 @@ private const val ROOT_TAG = "subsonic-response"
private class XmlFailure( private class XmlFailure(
val code: Int, val code: Int,
message: String, message: String,
val uiMessage: UiMessage? = null,
) : RuntimeException(message) ) : RuntimeException(message)
private class StopReading : RuntimeException() private class StopReading : RuntimeException()
@@ -240,7 +243,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
) { ) {
depth++ depth++
if (depth == 1) { 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 (attributes.getValue("status") == "failed") statusFailed = true
} }
if (qName == "error") { if (qName == "error") {
@@ -262,7 +265,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
} }
override fun endDocument() { 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( protected open fun onElementStart(
@@ -328,12 +331,12 @@ private fun parse(
try { try {
secureParserFactory().newSAXParser().parse(input, handler) secureParserFactory().newSAXParser().parse(input, handler)
} catch (failure: XmlFailure) { } catch (failure: XmlFailure) {
throw SubsonicException(failure.code, failure.message ?: "Unknown error") throw SubsonicException(failure.code, failure.message ?: "Unknown error", failure.uiMessage)
} catch (_: StopReading) { } catch (_: StopReading) {
} catch (failure: SAXException) { } 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) { } 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 package com.subtracks.data.sync
import android.util.Log 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.db.SubtracksDatabase
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.subsonic.SubsonicException
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -21,7 +25,7 @@ sealed interface SyncStatus {
data object Success : SyncStatus data object Success : SyncStatus
data class Failed( data class Failed(
val message: String, val message: UiMessage,
) : SyncStatus ) : SyncStatus
} }
@@ -29,7 +33,7 @@ class SyncManager(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val showMessage: (String) -> Unit = {}, private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) { ) {
private val requests = Channel<Unit>(Channel.CONFLATED) private val requests = Channel<Unit>(Channel.CONFLATED)
@@ -48,22 +52,37 @@ class SyncManager(
private suspend fun runSync() { private suspend fun runSync() {
if (sourceRepository.offline.value) { if (sourceRepository.offline.value) {
showMessage("Offline mode is on") val offline = UiMessage(R.string.sync_offline)
_status.value = SyncStatus.Failed("Offline mode is on") showMessage(offline)
_status.value = SyncStatus.Failed(offline)
return return
} }
_status.value = SyncStatus.Running _status.value = SyncStatus.Running
val result = val result =
try { try {
val source = sourceRepository.activeMusicSource() ?: error("No server configured") val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
SyncService(db, source).sync() SyncService(db, source).sync()
SyncStatus.Success SyncStatus.Success
} catch (cancellation: CancellationException) { } catch (cancellation: CancellationException) {
throw cancellation throw cancellation
} catch (failure: Exception) { } catch (failure: Exception) {
Log.w(TAG, "Sync failed", failure) Log.w(TAG, "Sync failed", failure)
val message = failure.message ?: "unknown error" val message =
showMessage("Sync failed: $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) SyncStatus.Failed(message)
} }
queueRepository.invalidateLibraryCache() queueRepository.invalidateLibraryCache()
@@ -74,3 +93,5 @@ class SyncManager(
const val TAG = "SubtracksSync" const val TAG = "SubtracksSync"
} }
} }
private class NoServerException : IllegalStateException()
@@ -6,6 +6,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
import com.subtracks.R import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.createAndroidDatabase import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.ArtworkFetcher import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -55,8 +56,8 @@ fun appModule(
http: OkHttpClient, http: OkHttpClient,
) = module { ) = module {
val mainHandler = Handler(Looper.getMainLooper()) val mainHandler = Handler(Looper.getMainLooper())
val toast: (String) -> Unit = { message -> val toast: (UiMessage) -> Unit = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } mainHandler.post { Toast.makeText(context, message.resolve(context), Toast.LENGTH_SHORT).show() }
} }
single { createAndroidDatabase(context) } single { createAndroidDatabase(context) }
single { http } single { http }
@@ -11,6 +11,8 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
@@ -235,13 +237,13 @@ internal fun queueMediaItem(
return builder.build() return builder.build()
} }
internal fun playbackErrorMessage(error: PlaybackException): String = internal fun playbackErrorMessage(error: PlaybackException): UiMessage =
when (error.errorCode) { when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, 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 package com.subtracks.playback
import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
@@ -108,7 +109,7 @@ class PlaybackController(
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val connection: PlayerConnection, private val connection: PlayerConnection,
private val downloads: DownloadRepository, 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 scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
) { ) {
private val _state = MutableStateFlow(PlaybackState()) private val _state = MutableStateFlow(PlaybackState())
@@ -1414,7 +1415,7 @@ class PlaybackController(
refresh() refresh()
} }
override fun onError(message: String) { override fun onError(message: UiMessage) {
showMessage(message) showMessage(message)
refresh() refresh()
} }
@@ -1,5 +1,6 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
interface PlayerHandle { interface PlayerHandle {
@@ -75,7 +76,7 @@ interface PlayerHandle {
fun onEvents() 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.R import com.subtracks.R
import com.subtracks.UiException
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
@@ -99,7 +100,10 @@ class AddSourceViewModel(
resources.getString(R.string.connection_ok) 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, isError = result.isFailure,
) )
+2
View File
@@ -218,8 +218,10 @@
<string name="download_notification_in_progress">In progress</string> <string name="download_notification_in_progress">In progress</string>
<string name="playback_error_network">Can\'t reach the server. Check your connection.</string> <string name="playback_error_network">Can\'t reach the server. Check your connection.</string>
<string name="playback_error_refused">The server refused to stream this track.</string> <string name="playback_error_refused">The server refused to stream this track.</string>
<string name="playback_error">Playback error: %1$s</string>
<string name="star_update_failed">Could not update star</string> <string name="star_update_failed">Could not update star</string>
<string name="sync_offline">Offline mode is on</string> <string name="sync_offline">Offline mode is on</string>
<string name="error_no_server">No server configured</string>
<string name="sync_failed">Sync failed</string> <string name="sync_failed">Sync failed</string>
<string name="sync_failed_detail">Sync failed: %1$s</string> <string name="sync_failed_detail">Sync failed: %1$s</string>
@@ -1,21 +1,29 @@
package com.subtracks.data.download package com.subtracks.data.download
import android.content.Context
import android.content.res.Resources
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DownloadNotificationTest { class DownloadNotificationTest {
private val resources: Resources = ApplicationProvider.getApplicationContext<Context>().resources
@Test @Test
fun nothingInFlightMeansNoNotification() { fun nothingInFlightMeansNoNotification() {
assertNull(downloadNotification(emptyList())) assertNull(downloadNotification(resources, emptyList()))
assertNull(downloadNotification(listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed)))) assertNull(downloadNotification(resources, listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed))))
} }
@Test @Test
fun oneRunningDownloadReportsItsShare() { fun oneRunningDownloadReportsItsShare() {
val notification = downloadNotification(listOf(row("s1", DownloadStatus.Running, bytes = 50, total = 100))) val notification = downloadNotification(resources, listOf(row("s1", DownloadStatus.Running, bytes = 50, total = 100)))
assertEquals("Downloading 1 song", notification?.title) assertEquals("Downloading 1 song", notification?.title)
assertEquals("50%", notification?.text) assertEquals("50%", notification?.text)
@@ -26,6 +34,7 @@ class DownloadNotificationTest {
fun theProgressIsTheShareOfEveryActiveDownload() { fun theProgressIsTheShareOfEveryActiveDownload() {
val notification = val notification =
downloadNotification( downloadNotification(
resources,
listOf( listOf(
row("s1", DownloadStatus.Running, bytes = 50, total = 100), row("s1", DownloadStatus.Running, bytes = 50, total = 100),
row("s2", DownloadStatus.Queued, bytes = 150, total = 300), row("s2", DownloadStatus.Queued, bytes = 150, total = 300),
@@ -39,7 +48,7 @@ class DownloadNotificationTest {
@Test @Test
fun anUnknownTotalLeavesTheProgressIndeterminate() { fun anUnknownTotalLeavesTheProgressIndeterminate() {
val notification = downloadNotification(listOf(row("s1", DownloadStatus.Running))) val notification = downloadNotification(resources, listOf(row("s1", DownloadStatus.Running)))
assertEquals("In progress", notification?.text) assertEquals("In progress", notification?.text)
assertNull(notification?.progress) assertNull(notification?.progress)
@@ -1,6 +1,7 @@
package com.subtracks.data.repo package com.subtracks.data.repo
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.net.Uri import android.net.Uri
import androidx.room3.Room import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
@@ -60,6 +61,7 @@ class DownloadRepositoryTest {
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
private val prefs = fakeUserPreferences() private val prefs = fakeUserPreferences()
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private lateinit var resources: Resources
private val requestedArt = CopyOnWriteArrayList<String>() private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false private var failArtworkFetch = false
@@ -74,6 +76,7 @@ class DownloadRepositoryTest {
@Before @Before
fun setUp() { fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db = db =
Room Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
@@ -92,7 +95,7 @@ class DownloadRepositoryTest {
dir, dir,
artworkStore = artwork, artworkStore = artwork,
artworkFetcher = fetcher, artworkFetcher = fetcher,
showMessage = { messages += it }, showMessage = { messages += it.resolve(resources) },
scope = scope, scope = scope,
) )
} }
@@ -1,6 +1,7 @@
package com.subtracks.data.repo package com.subtracks.data.repo
import android.content.Context import android.content.Context
import android.content.res.Resources
import androidx.room3.Room import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@@ -40,10 +41,12 @@ class LibraryRepositoryTest {
private lateinit var repository: LibraryRepository private lateinit var repository: LibraryRepository
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
private val messages = ArrayList<String>() private val messages = ArrayList<String>()
private lateinit var resources: Resources
@Before @Before
fun setUp() { fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db = db =
Room Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
@@ -57,7 +60,7 @@ class LibraryRepositoryTest {
db, db,
sourceRepository, sourceRepository,
NetworkServerActionSink(sourceRepository), NetworkServerActionSink(sourceRepository),
showMessage = { messages += it }, showMessage = { messages += it.resolve(resources) },
scope = scope, scope = scope,
) )
} }
@@ -1,6 +1,7 @@
package com.subtracks.data.repo package com.subtracks.data.repo
import android.content.Context import android.content.Context
import android.content.res.Resources
import androidx.room3.Room import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@@ -49,10 +50,12 @@ class SourceRepositoryTest {
private lateinit var artwork: ArtworkStore private lateinit var artwork: ArtworkStore
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
private val messages = ArrayList<String>() private val messages = ArrayList<String>()
private lateinit var resources: Resources
@Before @Before
fun setUp() { fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db = db =
Room Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
@@ -61,7 +64,8 @@ class SourceRepositoryTest {
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
prefs = fakeUserPreferences() prefs = fakeUserPreferences()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
repository = SourceRepository(db, OkHttpClient(), prefs, artwork, showMessage = { messages += it }, scope = scope) repository =
SourceRepository(db, OkHttpClient(), prefs, artwork, showMessage = { messages += it.resolve(resources) }, scope = scope)
} }
@After @After
@@ -1,6 +1,7 @@
package com.subtracks.data.sync package com.subtracks.data.sync
import android.content.Context import android.content.Context
import android.content.res.Resources
import androidx.room3.Room import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@@ -45,10 +46,12 @@ class SyncManagerTest {
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private lateinit var server: MockWebServer private lateinit var server: MockWebServer
private lateinit var resources: Resources
@Before @Before
fun setUp() { fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db = db =
Room Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
@@ -58,7 +61,7 @@ class SyncManagerTest {
sourceRepository = sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope) SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
queueRepository = QueueRepository(db) queueRepository = QueueRepository(db)
manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it }, scope = scope) manager = SyncManager(db, sourceRepository, queueRepository, showMessage = { messages += it.resolve(resources) }, scope = scope)
server = MockWebServer() server = MockWebServer()
server.start() server.start()
} }
@@ -76,7 +79,7 @@ class SyncManagerTest {
manager.requestSync() manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(listOf("Sync failed: No server configured"), messages) assertEquals(listOf("No server configured"), messages)
} }
@Test @Test
@@ -242,6 +245,6 @@ class SyncManagerTest {
manager.requestSync() manager.requestSync()
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals("Offline mode is on", (status as SyncStatus.Failed).message) assertEquals("Offline mode is on", (status as SyncStatus.Failed).message.resolve(resources))
} }
} }
@@ -1,5 +1,6 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.UiMessage
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
class FakePlayerConnection( class FakePlayerConnection(
@@ -232,7 +233,7 @@ class FakePlayerHandle : PlayerHandle {
notifyEvents() notifyEvents()
} }
fun fail(message: String) = listeners.forEach { it.onError(message) } fun fail(message: UiMessage) = listeners.forEach { it.onError(message) }
fun startBuffering() { fun startBuffering() {
idle = false idle = false
@@ -1,12 +1,15 @@
package com.subtracks.playback package com.subtracks.playback
import android.content.Context import android.content.Context
import android.content.res.Resources
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.room3.Room import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.R
import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.UiMessage
import com.subtracks.awaitUntil import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
@@ -73,10 +76,12 @@ class PlaybackControllerTest {
private lateinit var handle: FakePlayerHandle private lateinit var handle: FakePlayerHandle
private lateinit var controller: PlaybackController private lateinit var controller: PlaybackController
private val messages = CopyOnWriteArrayList<String>() private val messages = CopyOnWriteArrayList<String>()
private lateinit var resources: Resources
@Before @Before
fun setUp() { fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db = db =
Room Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
@@ -112,7 +117,7 @@ class PlaybackControllerTest {
queues, queues,
FakePlayerConnection(handle), FakePlayerConnection(handle),
downloads, downloads,
showMessage = { messages += it }, showMessage = { messages += it.resolve(resources) },
scope = controllerScope, scope = controllerScope,
) )
} }
@@ -1365,7 +1370,7 @@ class PlaybackControllerTest {
controller.playAlbum(1, "al1", 0) controller.playAlbum(1, "al1", 0)
await { handle.operations.contains("play") } await { handle.operations.contains("play") }
handle.fail("Can't reach the server. Check your connection.") handle.fail(UiMessage(R.string.playback_error_network))
await { messages.isNotEmpty() } await { messages.isNotEmpty() }
assertEquals(listOf("Can't reach the server. Check your connection."), messages.toList()) assertEquals(listOf("Can't reach the server. Check your connection."), messages.toList())
@@ -1377,9 +1382,9 @@ class PlaybackControllerTest {
val timeout = PlaybackException("net", null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) val timeout = PlaybackException("net", null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT)
val refused = PlaybackException("http", null, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS) val refused = PlaybackException("http", null, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)
assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(failed)) assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(failed).resolve(resources))
assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(timeout)) assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(timeout).resolve(resources))
assertEquals("The server refused to stream this track.", playbackErrorMessage(refused)) assertEquals("The server refused to stream this track.", playbackErrorMessage(refused).resolve(resources))
} }
@Test @Test
+1 -1
View File
@@ -146,7 +146,7 @@ Resource names are `snake_case`. They were derived once from the old Flutter ARB
Per-app language selection (Android 13+) is generated by AGP: `androidResources.generateLocaleConfig = true` with `res/resources.properties` supplying the default locale. `StringResourcesTest` guards the translations: every locale resource must exist in the default with the same kind and no placeholder the default lacks, which is what caught a duplicated `{value}` in the Thai ARB during migration. Per-app language selection (Android 13+) is generated by AGP: `androidResources.generateLocaleConfig = true` with `res/resources.properties` supplying the default locale. `StringResourcesTest` guards the translations: every locale resource must exist in the default with the same kind and no placeholder the default lacks, which is what caught a duplicated `{value}` in the Thai ARB during migration.
Some text is still English-only: transient toasts and status strings produced below the UI (`showMessage` and `SyncStatus.Failed`), the download progress notification, playback error messages, and Subsonic parser exceptions. Persisted download failures are no longer free text: `SongDownload.error` is a `DownloadError` enum mapped to `@StringRes` and stored by name (unknown legacy values degrade to `DownloadError.Failed`), so `SongInfoDialog` resolves it in the current locale. Text produced below the UI is typed, not free-form English. Repositories, `SyncManager` and playback report a `UiMessage` (a string-resource id plus format args) through `showMessage`, and the UI resolves it in the current locale; `SyncStatus.Failed` carries one too. Subsonic and playback failures set a localized `UiMessage` on the exception (the raw message stays as the developer detail/log text), and the download progress notification is formatted from resources. Persisted download failures are also typed: `SongDownload.error` is a `DownloadError` enum mapped to `@StringRes` and stored by name (unknown legacy values degrade to `DownloadError.Failed`), so `SongInfoDialog` resolves it in the current locale. The remaining non-localized strings are internal invariants and log tags that are never shown.
## Security and privacy ## Security and privacy