mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some text is still English-only: transient toasts and status strings produced below the UI (`showMessage` and `SyncStatus.Failed`), the download progress notification, playback error messages, and Subsonic parser exceptions. Persisted download failures are no longer free text: `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.
|
||||
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.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
|
||||
Reference in New Issue
Block a user