mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
@@ -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\"/>")
|
||||
|
||||
Reference in New Issue
Block a user