diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index 7498a2e6..52d44b26 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -130,5 +130,25 @@ class DownloadErrorConverter { fun fromDownloadError(error: DownloadError): String = error.name @ColumnTypeConverter - fun toDownloadError(value: String): DownloadError = DownloadError.entries.firstOrNull { it.name == value } ?: DownloadError.Failed + fun toDownloadError(value: String): DownloadError = + DownloadError.entries.firstOrNull { it.name == value } + ?: LEGACY_DOWNLOAD_ERRORS[value] + ?: DownloadError.Failed } + +private val LEGACY_DOWNLOAD_ERRORS = + mapOf( + "Not enough space to download" to DownloadError.NoSpace, + "Storage is unavailable" to DownloadError.StorageUnavailable, + "Could not write the download" to DownloadError.WriteFailed, + "The server refused to send this track" to DownloadError.ServerRefused, + "The server redirected too many times" to DownloadError.TooManyRedirects, + "The download could not be resumed" to DownloadError.ResumeFailed, + "Download failed" to DownloadError.Failed, + "The downloaded file is missing" to DownloadError.FileMissing, + "The server sent an error instead of the track" to DownloadError.ServerError, + "Can't download: the server address is unavailable" to DownloadError.NoAddress, + "Nothing left to download from this list" to DownloadError.NothingLeft, + "The download stopped unexpectedly" to DownloadError.Stopped, + "Can't download from a source that isn't active" to DownloadError.InactiveSource, + ) 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 0316cd30..771ee249 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt @@ -2,7 +2,6 @@ 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 @@ -73,10 +72,6 @@ class SyncManager( UiMessage(R.string.error_no_server) } - is UiException -> { - failure.uiMessage - } - else -> { (failure as? SubsonicException)?.uiMessage ?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: "")) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 76da6ba3..542027f9 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -28,6 +28,7 @@ import coil3.SingletonImageLoader import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.subtracks.MainActivity +import com.subtracks.R import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.data.source.StarType @@ -176,7 +177,7 @@ class PlaybackService : MediaSessionService() { CommandButton .Builder(if (isStarred) CommandButton.ICON_STAR_FILLED else CommandButton.ICON_STAR_UNFILLED) .setSessionCommand(STAR_COMMAND) - .setDisplayName(if (isStarred) "Unstar" else "Star") + .setDisplayName(getString(if (isStarred) R.string.actions_unstar else R.string.actions_star)) .build() private fun streamingClient(): OkHttpClient = diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt index 35d6e38a..1b368271 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt @@ -93,8 +93,8 @@ class DownloadsViewModel( internal fun buildTree( songs: List, - unknownArtist: String = "Unknown artist", - unknownAlbum: String = "Unknown album", + unknownArtist: String, + unknownAlbum: String, ): DownloadTree = DownloadTree( artists = 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 5d57a0e3..864f6d74 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt @@ -7,6 +7,7 @@ import com.subtracks.R import com.subtracks.UiException import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.source.subsonic.SubsonicException import com.subtracks.data.sync.SyncManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -101,8 +102,8 @@ class AddSourceViewModel( } }, { error -> - (error as? UiException)?.uiMessage?.resolve(resources) - ?: resources.getString(R.string.connection_failed, error.message) + val ui = (error as? UiException)?.uiMessage ?: (error as? SubsonicException)?.uiMessage + ui?.resolve(resources) ?: resources.getString(R.string.connection_failed, error.message) }, ), isError = result.isFailure, diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 29d86580..670b16c9 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -17,7 +17,6 @@ Artista Gênero Estrelado por - Ano %1$d song %1$d songs diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 366cee1a..fd7cf00b 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -16,7 +16,6 @@ 歌手 根据类型 已收藏 - 年份 %1$d 首歌曲 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 64575407..4fa76a43 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -16,7 +16,6 @@ 歌手 根據類型 已收藏 - 年份 %1$d 首歌曲 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 48fc8b3e..86500053 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -13,7 +13,6 @@ Umělec Podle žánru Ohodnocené - Rok Umělce Často přehrávané Názvu diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 37188d53..bff90524 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -17,7 +17,6 @@ Künstler Nach Genre Favoriten - Jahr %1$d Song %1$d Songs diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 32207deb..9ce0a8ed 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -17,7 +17,6 @@ Artista Género Estrellas - Año %1$d Canción %1$d Canciones diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 0a35e374..cfc260b5 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -17,7 +17,6 @@ Musikaria Generoa Gogokoak - Urtea Kanta bat %1$d kanta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 68c5c7a6..3e579300 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -17,7 +17,6 @@ Artiste Par Genre Favoris - Année %1$d chanson %1$d chansons diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 0e3ad800..cf737f15 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -17,7 +17,6 @@ Artista Por xénero Favoritas - Ano %1$d canción %1$d cancións diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 899e6ce5..7b9774c3 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -16,7 +16,6 @@ Előadó Stílus Kedvencek között - Év Előadó Gyakran játszott Név diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 99e10ea6..930fe03c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -17,7 +17,6 @@ Artista Per genere Preferiti - Anno %1$d brano %1$d brani diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 6fecd0a6..3f3dd242 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -8,7 +8,6 @@ 設定 アルブム 星付きアルバム - 年 よく聴くアルバム 最近再生した 検索 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7b09f9f7..ede44b78 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -16,7 +16,6 @@ 아티스트 장르 평가됨 - 년 %1$d 노래 diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 6d2e4490..4c65ead9 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -17,7 +17,6 @@ Artist Etter sjanger Stjernemerket - År %1$d sang %1$d sanger diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5a260428..2cb4221e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -13,7 +13,6 @@ Artysta Wg gatunku Ulubione - Rok Wg wykonawcy Często odtwarzane Wg nazwy diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index bc8424d7..8b580c96 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -13,7 +13,6 @@ Artista Por Género Favoritos - Ano Por Artista Mais Tocado Por Nome diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 292a4a40..c9b619a3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -17,7 +17,6 @@ По исполнителю По жанру Избранные - По годам %1$d трек %1$d треков diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 84155e75..679956de 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -17,7 +17,6 @@ Artist Genre Markerad - År %1$d låt %1$d låtar diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index d1a01b2a..6511f411 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -17,7 +17,6 @@ கலைஞர் வகை நடித்தார் - ஆண்டு %1$d பாடல் %1$d பாடல்கள் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 7b45b7a3..bf3d5f68 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -16,7 +16,6 @@ ศิลปิน ประเภท ให้ดาว - ปี %1$d เพลง %1$d เพลง diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7a198e54..30015a48 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -17,7 +17,6 @@ Sanatçı Türe göre Yıldızlı - Yıl %1$d şarkı %1$d şarkı diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c397c902..efba180e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -17,7 +17,6 @@ Виконавець Жанр Улюблене - Рік %1$d пісня %1$d пісні diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 18827642..6b44cb48 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -17,7 +17,6 @@ Theo nghệ sĩ Theo thể loại Theo yêu thích - Theo năm %1$d bài hát %1$d bài hát diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b440ae3..1f206a31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,12 +27,10 @@ Albums Artists Playlists - Queue Album Artist Genre Starred - Year %1$s\n\nDeleting will free %2$s Delete downloads Added @@ -222,7 +220,6 @@ Could not update star Offline mode is on No server configured - Sync failed Sync failed: %1$s Not enough space to download @@ -286,13 +283,4 @@ %1$d plays - MP3 - AAC - ALAC - FLAC - Ogg - Vorbis - Opus - WebM - WAV diff --git a/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt b/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt index 374d7b48..e2764034 100644 --- a/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt +++ b/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt @@ -24,6 +24,12 @@ class StringResourcesTest { val default = parse(defaultFile) assertTrue("default strings.xml should not be empty", default.isNotEmpty()) + default.forEach { (name, resource) -> + if (resource.kind == "plurals") { + assertTrue("$name in values/strings.xml is missing the required 'other' quantity", "other" in resource.quantities) + } + } + localeFiles().forEach { file -> val locale = parse(file) locale.forEach { (name, resource) -> @@ -35,12 +41,12 @@ class StringResourcesTest { "$name has an invalid quantity in ${file.parentFile.name}", resource.quantities.all { it in validQuantities }, ) + assertTrue("$name in ${file.parentFile.name} is missing the required 'other' quantity", "other" in resource.quantities) } - val baseArgs = placeholderIndices(base.text) - val localeArgs = placeholderIndices(resource.text) - assertTrue( - "$name in ${file.parentFile.name} uses placeholders $localeArgs not present in the default $baseArgs", - baseArgs.containsAll(localeArgs), + assertEquals( + "$name placeholders differ in ${file.parentFile.name}", + placeholders(base.text), + placeholders(resource.text), ) } } @@ -84,6 +90,6 @@ class StringResourcesTest { return (0 until items.length).joinToString(" ") { items.item(it).textContent } } - private fun placeholderIndices(text: String): Set = - Regex("""%(\d+)\$""").findAll(text).mapTo(mutableSetOf()) { it.groupValues[1].toInt() } + private fun placeholders(text: String): Map = + Regex("""%(\d+)\$([a-zA-Z])""").findAll(text).associate { it.groupValues[1].toInt() to it.groupValues[2][0] } } diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt index 2481288d..c80fa3a0 100644 --- a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt @@ -17,6 +17,8 @@ class DownloadTreeTest { song("s3", "Three", "ar1", "Artist", "al2", "Second", size = 5), song("s4", "Four", "ar2", "Other", "al3", "Third", size = 1), ), + "Unknown artist", + "Unknown album", ) assertEquals(listOf("Artist", "Other"), tree.artists.map { it.name }) @@ -37,7 +39,7 @@ class DownloadTreeTest { @Test fun aSongWithNoArtistOrAlbumIsStillShown() { - val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3))) + val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3)), "Unknown artist", "Unknown album") val artist = tree.artists.single() assertEquals("Unknown artist", artist.name) diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt index 1a27dc4e..5bc09a27 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt @@ -104,6 +104,20 @@ class AddSourceViewModelTest { assertEquals("Failed: Wrong username", state.message) } + @Test + fun aMalformedResponseIsReportedWithTheLocalizedMessage() { + val viewModel = viewModel() + viewModel.setAddress(server.url("/").toString()) + viewModel.setUsername("u") + viewModel.setPassword("p") + server.enqueue(MockResponse().setBody("not an xml document")) + + viewModel.testConnection() + await { !viewModel.state.value.busy && viewModel.state.value.isError } + + assertEquals("Malformed response from the server", viewModel.state.value.message) + } + @Test fun aSuccessfulPingClearsAnEarlierError() { val viewModel = viewModel() diff --git a/docs/architecture.md b/docs/architecture.md index ea50f633..c8c06ec6 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. -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. +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 (rows written before the enum are mapped back to their error, and anything unrecognized degrades 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