feat(android): #618 Phase 2 — search falls back to cached entities when offline
android / Build + lint + test (push) Has been cancelled
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:
+8
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+8
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+8
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user