localize user-facing text into string resources

Move every exposed UI string into res/values/strings.xml (+ plurals) and
read it via stringResource/pluralStringResource or getString, with 27
locale files carried over from the Flutter app's Weblate translations.
Only strings whose English is unchanged reuse an old translation; reworded
ones get fresh keys.

Persisted download failures become a DownloadError enum mapped to string
resources (stored by name, unknown legacy values degrade to Failed), the
per-app locale config is generated by AGP, and StringResourcesTest guards
locale keys and placeholders.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 88cd3764bf
commit 24757bda3f
74 changed files with 2206 additions and 361 deletions
@@ -1,5 +1,7 @@
package com.subtracks.data.download
import com.subtracks.data.model.DownloadError
class FakeDownloadEngine : DownloadEngine {
private val downloads = mutableMapOf<Long, EngineDownload>()
private var nextId = 1L
@@ -45,7 +47,7 @@ class FakeDownloadEngine : DownloadEngine {
}
fun fail(id: Long) {
downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed")
downloads[id] = EngineDownload(EngineStatus.Failed, error = DownloadError.Failed)
}
fun forget(id: Long) {
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -40,10 +41,10 @@ class SystemDownloadEngineTest {
}
@Test
fun failureReasonsReadAsSentences() {
assertEquals("Not enough space to download", failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE))
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN))
fun failureReasonsMapToErrors() {
assertEquals(DownloadError.NoSpace, failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE))
assertEquals(DownloadError.StorageUnavailable, failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
assertEquals(DownloadError.Failed, failureMessage(DownloadManager.ERROR_UNKNOWN))
}
@Test
@@ -15,6 +15,7 @@ import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
@@ -208,7 +209,7 @@ class DownloadRepositoryTest {
fun aFailedDownloadCanBeStartedAgain() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = "boom"))
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = DownloadError.Failed))
runBlocking { repository.download(1, "s1") }
@@ -220,14 +221,14 @@ class DownloadRepositoryTest {
fun aFailedDownloadIsNotRestartedByReconcile() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = null, error = "boom"))
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = null, error = DownloadError.Failed))
runBlocking { repository.reconcile() }
runBlocking { repository.reconcile() }
assertTrue(engine.requests.isEmpty())
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("boom", row(1, "s1")?.error)
assertEquals(DownloadError.Failed, row(1, "s1")?.error)
}
@Test
@@ -242,7 +243,7 @@ class DownloadRepositoryTest {
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("Download failed", row(1, "s1")?.error)
assertEquals(DownloadError.Failed, row(1, "s1")?.error)
assertFalse(file(1, "s1").exists())
}
@@ -1000,7 +1001,7 @@ class DownloadRepositoryTest {
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("The server sent an error instead of the track", row(1, "s1")?.error)
assertEquals(DownloadError.ServerError, row(1, "s1")?.error)
assertFalse(file(1, "s1").exists())
assertNull(repository.localUri("s1"))
}
@@ -0,0 +1,89 @@
package com.subtracks.i18n
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
class StringResourcesTest {
private val resDir = sequenceOf(File("src/main/res"), File("app/src/main/res")).first(File::isDirectory)
private val defaultFile = File(resDir, "values/strings.xml")
private data class Resource(
val kind: String,
val text: String,
val quantities: Set<String>,
)
private val validQuantities = setOf("zero", "one", "two", "few", "many", "other")
@Test
fun everyLocaleResourceExistsInDefaultWithMatchingTypeAndPlaceholders() {
val default = parse(defaultFile)
assertTrue("default strings.xml should not be empty", default.isNotEmpty())
localeFiles().forEach { file ->
val locale = parse(file)
locale.forEach { (name, resource) ->
val base = default[name]
assertTrue("$name in ${file.parentFile.name} is missing from values/strings.xml", base != null)
assertEquals("$name kind differs in ${file.parentFile.name}", base!!.kind, resource.kind)
if (resource.kind == "plurals") {
assertTrue(
"$name has an invalid quantity in ${file.parentFile.name}",
resource.quantities.all { it in validQuantities },
)
}
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),
)
}
}
}
private fun localeFiles(): List<File> =
resDir
.listFiles { file -> file.isDirectory && file.name.startsWith("values-") }
.orEmpty()
.map { File(it, "strings.xml") }
.filter(File::isFile)
private fun parse(file: File): Map<String, Resource> {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val resources = document.documentElement
val result = mutableMapOf<String, Resource>()
val nodes = resources.childNodes
for (index in 0 until nodes.length) {
val element = nodes.item(index) as? Element ?: continue
val name = element.getAttribute("name")
if (name.isEmpty()) continue
val text =
when (element.tagName) {
"plurals" -> pluralText(element)
else -> element.textContent
}
val quantities =
if (element.tagName == "plurals") {
val items = element.getElementsByTagName("item")
(0 until items.length).mapTo(mutableSetOf()) { (items.item(it) as Element).getAttribute("quantity") }
} else {
emptySet()
}
result[name] = Resource(element.tagName, text, quantities)
}
return result
}
private fun pluralText(element: Element): String {
val items = element.getElementsByTagName("item")
return (0 until items.length).joinToString(" ") { items.item(it).textContent }
}
private fun placeholderIndices(text: String): Set<Int> =
Regex("""%(\d+)\$""").findAll(text).mapTo(mutableSetOf()) { it.groupValues[1].toInt() }
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.test.performClick
import androidx.paging.PagingData
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
@@ -100,19 +101,43 @@ class LibraryOptionsTest {
@Test
fun playSortsAreHiddenUntilTheServerReturnsPlayData() {
assertEquals(
listOf("Name", "Artist", "Year", "Added", "Starred"),
listOf(
R.string.resources_sort_by_name,
R.string.resources_sort_by_artist,
R.string.resources_sort_by_year,
R.string.resources_sort_by_added,
R.string.resources_filter_starred,
),
sortOptionsFor(LibraryTab.Albums).map { it.label },
)
assertEquals(
listOf("Name", "Artist", "Year", "Added", "Starred", "Frequently played", "Recently played"),
listOf(
R.string.resources_sort_by_name,
R.string.resources_sort_by_artist,
R.string.resources_sort_by_year,
R.string.resources_sort_by_added,
R.string.resources_filter_starred,
R.string.resources_sort_by_frequently_played,
R.string.resources_sort_by_recently_played,
),
sortOptionsFor(LibraryTab.Albums, PlaySortAvailability(frequent = true, recent = true)).map { it.label },
)
assertEquals(
listOf("Name", "Albums", "Starred"),
listOf(
R.string.resources_sort_by_name,
R.string.resources_album_name,
R.string.resources_filter_starred,
),
sortOptionsFor(LibraryTab.Artists).map { it.label },
)
assertEquals(
listOf("Name", "Albums", "Starred", "Frequently played", "Recently played"),
listOf(
R.string.resources_sort_by_name,
R.string.resources_album_name,
R.string.resources_filter_starred,
R.string.resources_sort_by_frequently_played,
R.string.resources_sort_by_recently_played,
),
sortOptionsFor(LibraryTab.Artists, PlaySortAvailability(frequent = true, recent = true)).map { it.label },
)
}
@@ -225,7 +225,7 @@ class LibraryScreenTest {
composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) }
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(LibraryTab.Playlists.label)
.onNodeWithContentDescription(composeRule.activity.getString(LibraryTab.Playlists.label))
.fetchSemanticsNode()
.boundsInRoot.left
}
@@ -93,6 +93,8 @@ class DownloadsViewModelTest {
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
downloadRepository,
controller,
"Unknown artist",
"Unknown album",
)
}
@@ -1,67 +1,77 @@
package com.subtracks.ui.home
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HomeListInfoTest {
private val now = 1_700_000_000_000L
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
@Test
fun relativeTimeUsesFriendlyBuckets() {
assertNull(relativeTimeLabel(0, now))
assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now))
assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now))
assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now))
assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now))
assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now))
assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now))
assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now))
assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now))
assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now))
assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now))
assertNull(relativeTimeLabel(0, now, resources))
assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now, resources))
assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now, resources))
assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now, resources))
assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now, resources))
assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now, resources))
assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now, resources))
assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now, resources))
assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now, resources))
assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now, resources))
assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now, resources))
}
@Test
fun albumInfoMatchesTheSection() {
val album = album(created = secondsAgo(1), played = secondsAgo(3), playCount = 5, year = 1999)
assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now))
assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now))
assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now))
assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now))
assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now, resources))
assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now, resources))
assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now, resources))
assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now, resources))
}
@Test
fun albumInfoIsNullWhenTheSectionHasNothingToShow() {
val album = album(created = 0, played = null, playCount = 0, year = 0)
assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now, resources))
assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now, resources))
assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now, resources))
}
@Test
fun aDecadeListShowsTheYear() {
val album = album(created = 0, played = null, playCount = 0, year = 1999)
assertEquals("1999", albumListInfo(HomeListRequest(title = "1990s", decade = 1990), album, now))
assertEquals(
"1999",
albumListInfo(HomeListRequest(title = resources.getString(R.string.home_decade, 1990L), decade = 1990), album, now, resources),
)
}
@Test
fun artistInfoMatchesTheSection() {
val artist = artist(played = secondsAgo(3), playCount = 7)
assertEquals("3 days ago", artistListInfo(request(HomeSection.RecentlyPlayedArtists), artist, now))
assertEquals("7 plays", artistListInfo(request(HomeSection.MostPlayedArtists), artist, now))
assertNull(artistListInfo(request(HomeSection.MostPlayedArtists), artist.copy(playCount = 0, played = null), now))
assertEquals("3 days ago", artistListInfo(request(HomeSection.RecentlyPlayedArtists), artist, now, resources))
assertEquals("7 plays", artistListInfo(request(HomeSection.MostPlayedArtists), artist, now, resources))
assertNull(artistListInfo(request(HomeSection.MostPlayedArtists), artist.copy(playCount = 0, played = null), now, resources))
}
private fun secondsAgo(days: Long): Long = (now - days * 86_400_000L) / 1000L
private fun request(section: HomeSection) = HomeListRequest(title = section.title, section = section)
private fun request(section: HomeSection) = HomeListRequest(title = resources.getString(section.title), section = section)
private fun album(
created: Long,
@@ -141,7 +141,8 @@ class AddSourceViewModelTest {
private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
val syncManager = SyncManager(db, sourceRepository, QueueRepository(db), scope = repoScope)
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId).also { createdViewModels += it }
val resources = ApplicationProvider.getApplicationContext<Context>().resources
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
}
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")