mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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
|
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.DownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
|
|
||||||
@@ -9,14 +11,19 @@ data class DownloadNotification(
|
|||||||
val progress: Float?,
|
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 }
|
val active = rows.filter { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Running }
|
||||||
if (active.isEmpty()) return null
|
if (active.isEmpty()) return null
|
||||||
val total = active.sumOf { it.total }
|
val total = active.sumOf { it.total }
|
||||||
val progress = if (total > 0) (active.sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else null
|
val progress = if (total > 0) (active.sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else null
|
||||||
return DownloadNotification(
|
return DownloadNotification(
|
||||||
title = if (active.size == 1) "Downloading 1 song" else "Downloading ${active.size} songs",
|
title = resources.getQuantityString(R.plurals.download_notification_title, active.size, active.size),
|
||||||
text = progress?.let { "${(it * 100).toInt()}%" } ?: "In progress",
|
text =
|
||||||
|
progress?.let { resources.getString(R.string.download_notification_progress, (it * 100).toInt()) }
|
||||||
|
?: resources.getString(R.string.download_notification_in_progress),
|
||||||
progress = progress,
|
progress = progress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class DownloadNotifier(
|
|||||||
createChannel()
|
createChannel()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
repository.activeDownloads().collect { rows ->
|
repository.activeDownloads().collect { rows ->
|
||||||
val content = downloadNotification(rows)
|
val content = downloadNotification(context.resources, rows)
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
manager.cancel(ID)
|
manager.cancel(ID)
|
||||||
} else if (manager.areNotificationsEnabled()) {
|
} else if (manager.areNotificationsEnabled()) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.ArtworkFetcher
|
import com.subtracks.data.download.ArtworkFetcher
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
@@ -48,7 +50,7 @@ class DownloadRepository(
|
|||||||
private val downloadsDir: File,
|
private val downloadsDir: File,
|
||||||
private val artworkStore: ArtworkStore,
|
private val artworkStore: ArtworkStore,
|
||||||
private val artworkFetcher: ArtworkFetcher,
|
private val artworkFetcher: ArtworkFetcher,
|
||||||
private val showMessage: (String) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
|
||||||
) {
|
) {
|
||||||
@@ -111,11 +113,11 @@ class DownloadRepository(
|
|||||||
songId: String,
|
songId: String,
|
||||||
) = withContext(dispatcher) {
|
) = withContext(dispatcher) {
|
||||||
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
||||||
showMessage(NOT_ACTIVE)
|
showMessage(UiMessage(R.string.error_download_inactive_source))
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
if (sourceRepository.downloadUri(sourceId, songId) == null) {
|
if (sourceRepository.downloadUri(sourceId, songId) == null) {
|
||||||
showMessage(NO_ADDRESS)
|
showMessage(UiMessage(R.string.error_download_no_address))
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val promoted =
|
val promoted =
|
||||||
@@ -243,16 +245,16 @@ class DownloadRepository(
|
|||||||
refId: String,
|
refId: String,
|
||||||
) = withContext(dispatcher) {
|
) = withContext(dispatcher) {
|
||||||
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
||||||
showMessage(NOT_ACTIVE)
|
showMessage(UiMessage(R.string.error_download_inactive_source))
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val songIds = songIds(sourceId, list, refId)
|
val songIds = songIds(sourceId, list, refId)
|
||||||
if (songIds.isEmpty()) {
|
if (songIds.isEmpty()) {
|
||||||
showMessage(EMPTY_LIST)
|
showMessage(UiMessage(R.string.error_download_nothing_left))
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) {
|
if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) {
|
||||||
showMessage(NO_ADDRESS)
|
showMessage(UiMessage(R.string.error_download_no_address))
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
// Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and
|
// 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 POLL_MS = 1_000L
|
||||||
const val IN_FLIGHT_LIMIT = 8
|
const val IN_FLIGHT_LIMIT = 8
|
||||||
const val DELETE_CHUNK = 900
|
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
|
const val ART_TIMEOUT_MS = 60_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.paging.Pager
|
|||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.albumsDefaultQuery
|
import com.subtracks.data.db.albumsDefaultQuery
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistsDefaultQuery
|
||||||
@@ -38,7 +40,7 @@ class LibraryRepository(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
private val showMessage: (String) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
|
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
|
||||||
@@ -444,7 +446,7 @@ class LibraryRepository(
|
|||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||||
showMessage("Could not update star")
|
showMessage(UiMessage(R.string.star_update_failed))
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.subtracks.data.repo
|
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.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
@@ -35,7 +38,7 @@ class SourceRepository(
|
|||||||
private val prefs: UserPreferences,
|
private val prefs: UserPreferences,
|
||||||
private val artworkStore: ArtworkStore,
|
private val artworkStore: ArtworkStore,
|
||||||
networkMode: Flow<NetworkMode> = flowOf(NetworkMode.Wifi),
|
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 val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
) {
|
) {
|
||||||
private data class ActiveSource(
|
private data class ActiveSource(
|
||||||
@@ -222,7 +225,7 @@ class SourceRepository(
|
|||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
): Result<Boolean> =
|
): Result<Boolean> =
|
||||||
if (_offline.value) {
|
if (_offline.value) {
|
||||||
Result.failure(IllegalStateException("Offline mode is on"))
|
Result.failure(UiException(UiMessage(R.string.sync_offline), "Offline mode is on"))
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var fellBack = false
|
var fellBack = false
|
||||||
@@ -262,7 +265,7 @@ class SourceRepository(
|
|||||||
private fun disableTokenAuth(sourceId: Long) {
|
private fun disableTokenAuth(sourceId: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
db.sourcesDao().disableTokenAuth(sourceId)
|
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
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -9,6 +11,7 @@ import java.security.MessageDigest
|
|||||||
open class SubsonicException(
|
open class SubsonicException(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
message: String,
|
message: String,
|
||||||
|
val uiMessage: UiMessage? = null,
|
||||||
) : Exception(message)
|
) : Exception(message)
|
||||||
|
|
||||||
class SubsonicClient(
|
class SubsonicClient(
|
||||||
@@ -77,7 +80,7 @@ class SubsonicClient(
|
|||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
http.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
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())
|
return body(response.body.byteStream())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.subtracks.data.source.subsonic
|
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.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
@@ -282,7 +284,13 @@ class SubsonicSource(
|
|||||||
return supported
|
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(
|
private fun <T> entityBatches(
|
||||||
method: String,
|
method: String,
|
||||||
@@ -336,4 +344,5 @@ class SubsonicSource(
|
|||||||
|
|
||||||
private class PageCapExceeded(
|
private class PageCapExceeded(
|
||||||
message: String,
|
message: String,
|
||||||
) : SubsonicException(-1, message)
|
uiMessage: UiMessage,
|
||||||
|
) : SubsonicException(-1, message, uiMessage)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.subtracks.data.source.subsonic
|
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.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
@@ -224,6 +226,7 @@ private const val ROOT_TAG = "subsonic-response"
|
|||||||
private class XmlFailure(
|
private class XmlFailure(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
message: String,
|
message: String,
|
||||||
|
val uiMessage: UiMessage? = null,
|
||||||
) : RuntimeException(message)
|
) : RuntimeException(message)
|
||||||
|
|
||||||
private class StopReading : RuntimeException()
|
private class StopReading : RuntimeException()
|
||||||
@@ -240,7 +243,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
|||||||
) {
|
) {
|
||||||
depth++
|
depth++
|
||||||
if (depth == 1) {
|
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 (attributes.getValue("status") == "failed") statusFailed = true
|
||||||
}
|
}
|
||||||
if (qName == "error") {
|
if (qName == "error") {
|
||||||
@@ -262,7 +265,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun endDocument() {
|
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(
|
protected open fun onElementStart(
|
||||||
@@ -328,12 +331,12 @@ private fun parse(
|
|||||||
try {
|
try {
|
||||||
secureParserFactory().newSAXParser().parse(input, handler)
|
secureParserFactory().newSAXParser().parse(input, handler)
|
||||||
} catch (failure: XmlFailure) {
|
} catch (failure: XmlFailure) {
|
||||||
throw SubsonicException(failure.code, failure.message ?: "Unknown error")
|
throw SubsonicException(failure.code, failure.message ?: "Unknown error", failure.uiMessage)
|
||||||
} catch (_: StopReading) {
|
} catch (_: StopReading) {
|
||||||
} catch (failure: SAXException) {
|
} 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) {
|
} 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
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.util.Log
|
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.db.SubtracksDatabase
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -21,7 +25,7 @@ sealed interface SyncStatus {
|
|||||||
data object Success : SyncStatus
|
data object Success : SyncStatus
|
||||||
|
|
||||||
data class Failed(
|
data class Failed(
|
||||||
val message: String,
|
val message: UiMessage,
|
||||||
) : SyncStatus
|
) : SyncStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ class SyncManager(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val showMessage: (String) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
) {
|
) {
|
||||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||||
@@ -48,22 +52,37 @@ class SyncManager(
|
|||||||
|
|
||||||
private suspend fun runSync() {
|
private suspend fun runSync() {
|
||||||
if (sourceRepository.offline.value) {
|
if (sourceRepository.offline.value) {
|
||||||
showMessage("Offline mode is on")
|
val offline = UiMessage(R.string.sync_offline)
|
||||||
_status.value = SyncStatus.Failed("Offline mode is on")
|
showMessage(offline)
|
||||||
|
_status.value = SyncStatus.Failed(offline)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: error("No server configured")
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
SyncService(db, source).sync()
|
SyncService(db, source).sync()
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
Log.w(TAG, "Sync failed", failure)
|
Log.w(TAG, "Sync failed", failure)
|
||||||
val message = failure.message ?: "unknown error"
|
val message =
|
||||||
showMessage("Sync failed: $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)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
@@ -74,3 +93,5 @@ class SyncManager(
|
|||||||
const val TAG = "SubtracksSync"
|
const val TAG = "SubtracksSync"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class NoServerException : IllegalStateException()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.createAndroidDatabase
|
import com.subtracks.data.db.createAndroidDatabase
|
||||||
import com.subtracks.data.download.ArtworkFetcher
|
import com.subtracks.data.download.ArtworkFetcher
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
@@ -55,8 +56,8 @@ fun appModule(
|
|||||||
http: OkHttpClient,
|
http: OkHttpClient,
|
||||||
) = module {
|
) = module {
|
||||||
val mainHandler = Handler(Looper.getMainLooper())
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
val toast: (String) -> Unit = { message ->
|
val toast: (UiMessage) -> Unit = { message ->
|
||||||
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
|
mainHandler.post { Toast.makeText(context, message.resolve(context), Toast.LENGTH_SHORT).show() }
|
||||||
}
|
}
|
||||||
single { createAndroidDatabase(context) }
|
single { createAndroidDatabase(context) }
|
||||||
single { http }
|
single { http }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import androidx.media3.common.PlaybackException
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -235,13 +237,13 @@ internal fun queueMediaItem(
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun playbackErrorMessage(error: PlaybackException): String =
|
internal fun playbackErrorMessage(error: PlaybackException): UiMessage =
|
||||||
when (error.errorCode) {
|
when (error.errorCode) {
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
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
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -108,7 +109,7 @@ class PlaybackController(
|
|||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val connection: PlayerConnection,
|
private val connection: PlayerConnection,
|
||||||
private val downloads: DownloadRepository,
|
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 scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(PlaybackState())
|
private val _state = MutableStateFlow(PlaybackState())
|
||||||
@@ -1414,7 +1415,7 @@ class PlaybackController(
|
|||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(message: String) {
|
override fun onError(message: UiMessage) {
|
||||||
showMessage(message)
|
showMessage(message)
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
|
|
||||||
interface PlayerHandle {
|
interface PlayerHandle {
|
||||||
@@ -75,7 +76,7 @@ interface PlayerHandle {
|
|||||||
|
|
||||||
fun onEvents()
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UiException
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
@@ -99,7 +100,10 @@ class AddSourceViewModel(
|
|||||||
resources.getString(R.string.connection_ok)
|
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,
|
isError = result.isFailure,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -218,8 +218,10 @@
|
|||||||
<string name="download_notification_in_progress">In progress</string>
|
<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_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_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="star_update_failed">Could not update star</string>
|
||||||
<string name="sync_offline">Offline mode is on</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">Sync failed</string>
|
||||||
<string name="sync_failed_detail">Sync failed: %1$s</string>
|
<string name="sync_failed_detail">Sync failed: %1$s</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
package com.subtracks.data.download
|
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.DownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
class DownloadNotificationTest {
|
class DownloadNotificationTest {
|
||||||
|
private val resources: Resources = ApplicationProvider.getApplicationContext<Context>().resources
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nothingInFlightMeansNoNotification() {
|
fun nothingInFlightMeansNoNotification() {
|
||||||
assertNull(downloadNotification(emptyList()))
|
assertNull(downloadNotification(resources, emptyList()))
|
||||||
assertNull(downloadNotification(listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed))))
|
assertNull(downloadNotification(resources, listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun oneRunningDownloadReportsItsShare() {
|
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("Downloading 1 song", notification?.title)
|
||||||
assertEquals("50%", notification?.text)
|
assertEquals("50%", notification?.text)
|
||||||
@@ -26,6 +34,7 @@ class DownloadNotificationTest {
|
|||||||
fun theProgressIsTheShareOfEveryActiveDownload() {
|
fun theProgressIsTheShareOfEveryActiveDownload() {
|
||||||
val notification =
|
val notification =
|
||||||
downloadNotification(
|
downloadNotification(
|
||||||
|
resources,
|
||||||
listOf(
|
listOf(
|
||||||
row("s1", DownloadStatus.Running, bytes = 50, total = 100),
|
row("s1", DownloadStatus.Running, bytes = 50, total = 100),
|
||||||
row("s2", DownloadStatus.Queued, bytes = 150, total = 300),
|
row("s2", DownloadStatus.Queued, bytes = 150, total = 300),
|
||||||
@@ -39,7 +48,7 @@ class DownloadNotificationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun anUnknownTotalLeavesTheProgressIndeterminate() {
|
fun anUnknownTotalLeavesTheProgressIndeterminate() {
|
||||||
val notification = downloadNotification(listOf(row("s1", DownloadStatus.Running)))
|
val notification = downloadNotification(resources, listOf(row("s1", DownloadStatus.Running)))
|
||||||
|
|
||||||
assertEquals("In progress", notification?.text)
|
assertEquals("In progress", notification?.text)
|
||||||
assertNull(notification?.progress)
|
assertNull(notification?.progress)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
@@ -60,6 +61,7 @@ class DownloadRepositoryTest {
|
|||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val prefs = fakeUserPreferences()
|
private val prefs = fakeUserPreferences()
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
|
private lateinit var resources: Resources
|
||||||
private val requestedArt = CopyOnWriteArrayList<String>()
|
private val requestedArt = CopyOnWriteArrayList<String>()
|
||||||
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
||||||
private var failArtworkFetch = false
|
private var failArtworkFetch = false
|
||||||
@@ -74,6 +76,7 @@ class DownloadRepositoryTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
resources = context.resources
|
||||||
db =
|
db =
|
||||||
Room
|
Room
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
@@ -92,7 +95,7 @@ class DownloadRepositoryTest {
|
|||||||
dir,
|
dir,
|
||||||
artworkStore = artwork,
|
artworkStore = artwork,
|
||||||
artworkFetcher = fetcher,
|
artworkFetcher = fetcher,
|
||||||
showMessage = { messages += it },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
@@ -40,10 +41,12 @@ class LibraryRepositoryTest {
|
|||||||
private lateinit var repository: LibraryRepository
|
private lateinit var repository: LibraryRepository
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = ArrayList<String>()
|
private val messages = ArrayList<String>()
|
||||||
|
private lateinit var resources: Resources
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
resources = context.resources
|
||||||
db =
|
db =
|
||||||
Room
|
Room
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
@@ -57,7 +60,7 @@ class LibraryRepositoryTest {
|
|||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
showMessage = { messages += it },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
@@ -49,10 +50,12 @@ class SourceRepositoryTest {
|
|||||||
private lateinit var artwork: ArtworkStore
|
private lateinit var artwork: ArtworkStore
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = ArrayList<String>()
|
private val messages = ArrayList<String>()
|
||||||
|
private lateinit var resources: Resources
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
resources = context.resources
|
||||||
db =
|
db =
|
||||||
Room
|
Room
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
@@ -61,7 +64,8 @@ class SourceRepositoryTest {
|
|||||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
prefs = fakeUserPreferences()
|
prefs = fakeUserPreferences()
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
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
|
@After
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
@@ -45,10 +46,12 @@ class SyncManagerTest {
|
|||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
private lateinit var server: MockWebServer
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var resources: Resources
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
resources = context.resources
|
||||||
db =
|
db =
|
||||||
Room
|
Room
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
@@ -58,7 +61,7 @@ class SyncManagerTest {
|
|||||||
sourceRepository =
|
sourceRepository =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||||
queueRepository = QueueRepository(db)
|
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 = MockWebServer()
|
||||||
server.start()
|
server.start()
|
||||||
}
|
}
|
||||||
@@ -76,7 +79,7 @@ class SyncManagerTest {
|
|||||||
manager.requestSync()
|
manager.requestSync()
|
||||||
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
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
|
@Test
|
||||||
@@ -242,6 +245,6 @@ class SyncManagerTest {
|
|||||||
manager.requestSync()
|
manager.requestSync()
|
||||||
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
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
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
class FakePlayerConnection(
|
class FakePlayerConnection(
|
||||||
@@ -232,7 +233,7 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
notifyEvents()
|
notifyEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fail(message: String) = listeners.forEach { it.onError(message) }
|
fun fail(message: UiMessage) = listeners.forEach { it.onError(message) }
|
||||||
|
|
||||||
fun startBuffering() {
|
fun startBuffering() {
|
||||||
idle = false
|
idle = false
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.R
|
||||||
import com.subtracks.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
@@ -73,10 +76,12 @@ class PlaybackControllerTest {
|
|||||||
private lateinit var handle: FakePlayerHandle
|
private lateinit var handle: FakePlayerHandle
|
||||||
private lateinit var controller: PlaybackController
|
private lateinit var controller: PlaybackController
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
|
private lateinit var resources: Resources
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
resources = context.resources
|
||||||
db =
|
db =
|
||||||
Room
|
Room
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
@@ -112,7 +117,7 @@ class PlaybackControllerTest {
|
|||||||
queues,
|
queues,
|
||||||
FakePlayerConnection(handle),
|
FakePlayerConnection(handle),
|
||||||
downloads,
|
downloads,
|
||||||
showMessage = { messages += it },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = controllerScope,
|
scope = controllerScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1365,7 +1370,7 @@ class PlaybackControllerTest {
|
|||||||
controller.playAlbum(1, "al1", 0)
|
controller.playAlbum(1, "al1", 0)
|
||||||
await { handle.operations.contains("play") }
|
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() }
|
await { messages.isNotEmpty() }
|
||||||
|
|
||||||
assertEquals(listOf("Can't reach the server. Check your connection."), messages.toList())
|
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 timeout = PlaybackException("net", null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT)
|
||||||
val refused = PlaybackException("http", null, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)
|
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(failed).resolve(resources))
|
||||||
assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(timeout))
|
assertEquals("Can't reach the server. Check your connection.", playbackErrorMessage(timeout).resolve(resources))
|
||||||
assertEquals("The server refused to stream this track.", playbackErrorMessage(refused))
|
assertEquals("The server refused to stream this track.", playbackErrorMessage(refused).resolve(resources))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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.
|
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
|
## Security and privacy
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user