feat(android): #618 Phase 2 — search falls back to cached entities when offline
android / Build + lint + test (push) Has been cancelled

When ServerHealthController reports Offline or ServerDown, SearchRepository
runs Room LIKE queries against cached_artists / cached_albums / cached_tracks
instead of hitting /api/search. The screen draws a one-line hint above the
results so the user can tell server matches from on-device-only matches.

Adds searchByName / searchByTitle DAO methods; LOCAL_SEARCH_LIMIT=20 matches
the server's default page size.
This commit is contained in:
2026-06-04 12:35:01 -04:00
parent fced6b681e
commit 48de720514
6 changed files with 112 additions and 16 deletions
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums WHERE id = :id") @Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?> fun observeById(id: String): Flow<CachedAlbumEntity?>
@Query(
"SELECT * FROM cached_albums " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortTitle COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit") @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -18,6 +18,14 @@ interface CachedArtistDao {
@Query("SELECT * FROM cached_artists WHERE id = :id") @Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?> fun observeById(id: String): Flow<CachedArtistEntity?>
@Query(
"SELECT * FROM cached_artists " +
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortName COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit") @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -24,6 +24,14 @@ interface CachedTrackDao {
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity> suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@Query(
"SELECT * FROM cached_tracks " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY title COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit") @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -1,6 +1,11 @@
package com.fabledsword.minstrel.search.data package com.fabledsword.minstrel.search.data
import com.fabledsword.minstrel.api.endpoints.SearchApi import com.fabledsword.minstrel.api.endpoints.SearchApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -8,17 +13,34 @@ import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/** /**
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in * Cache-first when offline. When [ServerHealthController] is Healthy the
* the ViewModel, not here, so the repository stays trivial. * repository hits `/api/search` and returns the server's three-facet
* paged response. When health is Offline or ServerDown it falls back to
* Room LIKE queries against `cached_*` so the user can still find
* something to play from what's already on the device; the outcome's
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
* results" hint instead of pretending the server answered.
*/ */
@Singleton @Singleton
class SearchRepository @Inject constructor( class SearchRepository @Inject constructor(
retrofit: Retrofit, retrofit: Retrofit,
private val serverHealth: ServerHealthController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
) { ) {
private val api: SearchApi = retrofit.create() private val api: SearchApi = retrofit.create()
suspend fun search(query: String): SearchResponseRef { suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
ServerHealth.Offline, ServerHealth.ServerDown ->
SearchOutcome(localSearch(query), localOnly = true)
}
private suspend fun remoteSearch(query: String): SearchResponseRef {
val wire = api.search(query) val wire = api.search(query)
return SearchResponseRef( return SearchResponseRef(
artists = wire.artists.items.map { it.toDomain() }, artists = wire.artists.items.map { it.toDomain() },
@@ -26,4 +48,21 @@ class SearchRepository @Inject constructor(
tracks = wire.tracks.items.map { it.toDomain() }, tracks = wire.tracks.items.map { it.toDomain() },
) )
} }
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
)
} }
/**
* Wraps the search response with the signal of whether the result came
* from the server or from the local cached entities. The screen renders
* the same SearchResponseRef either way; the flag drives the offline
* banner copy.
*/
data class SearchOutcome(
val response: SearchResponseRef,
val localOnly: Boolean,
)
@@ -158,17 +158,28 @@ private fun ResultsPane(
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
is SearchResultsState.Loaded -> { is SearchResultsState.Loaded -> {
if (state.response.isEmpty) { if (state.response.isEmpty) {
CenteredHint("No matches for that query.") CenteredHint(
} else { if (state.localOnly) {
ResultsList( "No matches in your on-device library."
response = state.response, } else {
playingTrackId = playingTrackId, "No matches for that query."
onArtistClick = onArtistClick, },
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
) )
} else {
Column(modifier = Modifier.fillMaxSize()) {
if (state.localOnly) {
OfflineResultsHint()
}
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
} }
} }
} }
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
} }
} }
@Composable
private fun OfflineResultsHint() {
Text(
text = "Showing on-device matches only — the server is unreachable.",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable @Composable
private fun CenteredHint(text: String) { private fun CenteredHint(text: String) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
/** Empty query — screen shows "type to search" hint. */ /** Empty query — screen shows "type to search" hint. */
data object Idle : SearchResultsState data object Idle : SearchResultsState
data object Loading : SearchResultsState data object Loading : SearchResultsState
data class Loaded(val response: SearchResponseRef) : SearchResultsState data class Loaded(
val response: SearchResponseRef,
val localOnly: Boolean = false,
) : SearchResultsState
data class Error(val message: String) : SearchResultsState data class Error(val message: String) : SearchResultsState
} }
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
private suspend fun runSearch(q: String) { private suspend fun runSearch(q: String) {
internal.update { it.copy(results = SearchResultsState.Loading) } internal.update { it.copy(results = SearchResultsState.Loading) }
try { try {
val response = repository.search(q) val outcome = repository.search(q)
internal.update { it.copy(results = SearchResultsState.Loaded(response)) } internal.update {
it.copy(
results = SearchResultsState.Loaded(
response = outcome.response,
localOnly = outcome.localOnly,
),
)
}
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
) { ) {