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
@@ -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<Any> = 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)
@@ -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<SongDownload>): DownloadNotification? {
fun downloadNotification(
resources: Resources,
rows: Collection<SongDownload>,
): 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,
)
}
@@ -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()) {
@@ -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
}
}
@@ -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<Long?> = 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)
}
}
@@ -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<NetworkMode> = 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<Boolean> =
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))
}
}
@@ -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())
}
@@ -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 <T> entityBatches(
method: String,
@@ -336,4 +344,5 @@ class SubsonicSource(
private class PageCapExceeded(
message: String,
) : SubsonicException(-1, message)
uiMessage: UiMessage,
) : SubsonicException(-1, message, uiMessage)
@@ -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))
}
}
@@ -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<Unit>(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()
@@ -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 }
@@ -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))
}
@@ -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()
}
@@ -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) {
}
}
}
@@ -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,
)
+2
View File
@@ -218,8 +218,10 @@
<string name="download_notification_in_progress">In progress</string>
<string name="playback_error_network">Can\'t reach the server. Check your connection.</string>
<string name="playback_error_refused">The server refused to stream this track.</string>
<string name="playback_error">Playback error: %1$s</string>
<string name="star_update_failed">Could not update star</string>
<string name="sync_offline">Offline mode is on</string>
<string name="error_no_server">No server configured</string>
<string name="sync_failed">Sync failed</string>
<string name="sync_failed_detail">Sync failed: %1$s</string>
@@ -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