diff --git a/app/src/main/kotlin/com/subtracks/UiMessage.kt b/app/src/main/kotlin/com/subtracks/UiMessage.kt new file mode 100644 index 00000000..c5ad2e4f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/UiMessage.kt @@ -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 = 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) diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt index e65aa639..dd5144fa 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt @@ -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): DownloadNotification? { +fun downloadNotification( + resources: Resources, + rows: Collection, +): 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, ) } diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt index 6db323a2..cc04411c 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt @@ -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()) { 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 6d31d928..de10ebda 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -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 } } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 2c04e22a..1971a743 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -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 = 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) } } 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 0bfff69d..4fab802b 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -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 = 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 = 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)) } } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt index 8cfb4152..22551a4e 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt @@ -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()) } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index 8e43ab1e..676d61fe 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -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 entityBatches( method: String, @@ -336,4 +344,5 @@ class SubsonicSource( private class PageCapExceeded( message: String, -) : SubsonicException(-1, message) + uiMessage: UiMessage, +) : SubsonicException(-1, message, uiMessage) diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index d2449284..111b53fb 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -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)) } } diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt index 206bbdab..0316cd30 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt @@ -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(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() diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index f7dd0127..464b7b89 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -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 } diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 67eb8376..8f621b88 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -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)) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index b267e59c..bb2b6e37 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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() } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt index 47e940d2..813b86d1 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt @@ -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) { } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt index c39024cc..5d57a0e3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt @@ -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, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c2a6357..2b440ae3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -218,8 +218,10 @@ In progress Can\'t reach the server. Check your connection. The server refused to stream this track. + Playback error: %1$s Could not update star Offline mode is on + No server configured Sync failed Sync failed: %1$s diff --git a/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt b/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt index fd48fe82..a4f133eb 100644 --- a/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt @@ -1,21 +1,29 @@ 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.SongDownload import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class DownloadNotificationTest { + private val resources: Resources = ApplicationProvider.getApplicationContext().resources + @Test fun nothingInFlightMeansNoNotification() { - assertNull(downloadNotification(emptyList())) - assertNull(downloadNotification(listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed)))) + assertNull(downloadNotification(resources, emptyList())) + assertNull(downloadNotification(resources, listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed)))) } @Test 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("50%", notification?.text) @@ -26,6 +34,7 @@ class DownloadNotificationTest { fun theProgressIsTheShareOfEveryActiveDownload() { val notification = downloadNotification( + resources, listOf( row("s1", DownloadStatus.Running, bytes = 50, total = 100), row("s2", DownloadStatus.Queued, bytes = 150, total = 300), @@ -39,7 +48,7 @@ class DownloadNotificationTest { @Test fun anUnknownTotalLeavesTheProgressIndeterminate() { - val notification = downloadNotification(listOf(row("s1", DownloadStatus.Running))) + val notification = downloadNotification(resources, listOf(row("s1", DownloadStatus.Running))) assertEquals("In progress", notification?.text) assertNull(notification?.progress) 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 22066b22..2ceb220f 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -1,6 +1,7 @@ package com.subtracks.data.repo import android.content.Context +import android.content.res.Resources import android.net.Uri import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver @@ -60,6 +61,7 @@ class DownloadRepositoryTest { private lateinit var scope: CoroutineScope private val prefs = fakeUserPreferences() private val messages = CopyOnWriteArrayList() + private lateinit var resources: Resources private val requestedArt = CopyOnWriteArrayList() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList() private var failArtworkFetch = false @@ -74,6 +76,7 @@ class DownloadRepositoryTest { @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + resources = context.resources db = Room .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) @@ -92,7 +95,7 @@ class DownloadRepositoryTest { dir, artworkStore = artwork, artworkFetcher = fetcher, - showMessage = { messages += it }, + showMessage = { messages += it.resolve(resources) }, scope = scope, ) } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt index 5c9caba4..57257fdf 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt @@ -1,6 +1,7 @@ package com.subtracks.data.repo import android.content.Context +import android.content.res.Resources import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider @@ -40,10 +41,12 @@ class LibraryRepositoryTest { private lateinit var repository: LibraryRepository private lateinit var scope: CoroutineScope private val messages = ArrayList() + private lateinit var resources: Resources @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + resources = context.resources db = Room .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) @@ -57,7 +60,7 @@ class LibraryRepositoryTest { db, sourceRepository, NetworkServerActionSink(sourceRepository), - showMessage = { messages += it }, + showMessage = { messages += it.resolve(resources) }, scope = scope, ) } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index f5575c7e..bd8b8f29 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -1,6 +1,7 @@ package com.subtracks.data.repo import android.content.Context +import android.content.res.Resources import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider @@ -49,10 +50,12 @@ class SourceRepositoryTest { private lateinit var artwork: ArtworkStore private lateinit var scope: CoroutineScope private val messages = ArrayList() + private lateinit var resources: Resources @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + resources = context.resources db = Room .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) @@ -61,7 +64,8 @@ class SourceRepositoryTest { artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}")) prefs = fakeUserPreferences() 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 diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt index cc588ae4..0ec1e41f 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt @@ -1,6 +1,7 @@ package com.subtracks.data.sync import android.content.Context +import android.content.res.Resources import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider @@ -45,10 +46,12 @@ class SyncManagerTest { private lateinit var scope: CoroutineScope private val messages = CopyOnWriteArrayList() private lateinit var server: MockWebServer + private lateinit var resources: Resources @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + resources = context.resources db = Room .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) @@ -58,7 +61,7 @@ class SyncManagerTest { sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope) 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.start() } @@ -76,7 +79,7 @@ class SyncManagerTest { manager.requestSync() 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 @@ -242,6 +245,6 @@ class SyncManagerTest { manager.requestSync() 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)) } } diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index 89dab613..9a54f061 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -1,5 +1,6 @@ package com.subtracks.playback +import com.subtracks.UiMessage import java.util.concurrent.CopyOnWriteArrayList class FakePlayerConnection( @@ -232,7 +233,7 @@ class FakePlayerHandle : PlayerHandle { notifyEvents() } - fun fail(message: String) = listeners.forEach { it.onError(message) } + fun fail(message: UiMessage) = listeners.forEach { it.onError(message) } fun startBuffering() { idle = false diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 7146c32f..726ce5ac 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -1,12 +1,15 @@ package com.subtracks.playback import android.content.Context +import android.content.res.Resources import androidx.media3.common.PlaybackException import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.R import com.subtracks.TEST_TIMEOUT_MS +import com.subtracks.UiMessage import com.subtracks.awaitUntil import com.subtracks.cancelAndJoinBlocking import com.subtracks.data.db.SubtracksDatabase @@ -73,10 +76,12 @@ class PlaybackControllerTest { private lateinit var handle: FakePlayerHandle private lateinit var controller: PlaybackController private val messages = CopyOnWriteArrayList() + private lateinit var resources: Resources @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + resources = context.resources db = Room .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) @@ -112,7 +117,7 @@ class PlaybackControllerTest { queues, FakePlayerConnection(handle), downloads, - showMessage = { messages += it }, + showMessage = { messages += it.resolve(resources) }, scope = controllerScope, ) } @@ -1365,7 +1370,7 @@ class PlaybackControllerTest { controller.playAlbum(1, "al1", 0) 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() } 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 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(timeout)) - assertEquals("The server refused to stream this track.", playbackErrorMessage(refused)) + assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(failed).resolve(resources)) + assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(timeout).resolve(resources)) + assertEquals("The server refused to stream this track.", playbackErrorMessage(refused).resolve(resources)) } @Test diff --git a/docs/architecture.md b/docs/architecture.md index 59d0dc39..ea50f633 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. -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