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
@@ -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<Context>().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)
@@ -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<String>()
private lateinit var resources: Resources
private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false
@@ -74,6 +76,7 @@ class DownloadRepositoryTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
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,
)
}
@@ -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<String>()
private lateinit var resources: Resources
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
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,
)
}
@@ -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<String>()
private lateinit var resources: Resources
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
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
@@ -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<String>()
private lateinit var server: MockWebServer
private lateinit var resources: Resources
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
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))
}
}
@@ -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
@@ -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<String>()
private lateinit var resources: Resources
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
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