Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6d1fa9c
refactor(components): extract reusable search filter text field
PonceGL Jul 22, 2026
18c65dd
feat(playlist): add title/artist song matcher util
PonceGL Jul 22, 2026
00f56c9
feat(playlist): add search filter to playlist detail screen
PonceGL Jul 22, 2026
269b831
feat(playlist): disable playlist actions while filtering
PonceGL Jul 22, 2026
7a44866
test(playlist): add filterByQuery to SongFilterUtils with list coverage
PonceGL Aug 6, 2026
5dd2c73
test(playlist): add Compose instrumentation test for playlist search
PonceGL Aug 6, 2026
8f6f428
merge(dev): integrate feature/playlist-song-search
PonceGL Aug 6, 2026
5123cef
chore(build): add personal build type in app based on release
PonceGL Aug 6, 2026
0b7967d
chore(build): add mirrored personal build type in wear
PonceGL Aug 6, 2026
4e3a5bb
merge(dev): integrate chore/personal-build-type
PonceGL Aug 6, 2026
3296610
fix(debug): add missing locale overrides for the [D] app name suffix
PonceGL Aug 6, 2026
7841dac
Merge pull request #2 from PonceGL/bugfix/debug-app-name-missing-locales
PonceGL Aug 6, 2026
6bbc6ec
chore(build): add locale overrides for the personal app name
PonceGL Aug 6, 2026
ba9b3cc
fix(playlist): fold diacritics when filtering songs in a playlist
PonceGL Aug 6, 2026
f73e073
fix(search): use implicit AND in FTS queries, not the AND keyword
PonceGL Aug 6, 2026
ff2f25b
feat(wear): Wear OS companion app — playlist transfer, local playback…
PonceGL Aug 12, 2026
6816671
fix(wear): correcciones de la revisión de código de la app companion
PonceGL Aug 15, 2026
87a7f20
fix(wear): los tres ajustes de rendimiento no llegaban al reloj
PonceGL Aug 15, 2026
c547140
fix(wear): sincronización real de biblioteca, y arreglos de la prueba…
PonceGL Aug 15, 2026
ef20aed
Merge fix/wear-review-fixes: correcciones de revisión y de prueba en …
PonceGL Aug 15, 2026
d273914
chore(tooling): ignore graphify output and local claude config
PonceGL Aug 27, 2026
6882fa7
Merge remote-tracking branch 'origin/feature/playlist-song-search' in…
PonceGL Aug 27, 2026
dc59c7d
Merge remote-tracking branch 'origin/bugfix/debug-app-name-missing-lo…
PonceGL Aug 27, 2026
44d6b3c
Merge remote-tracking branch 'origin/bugfix/fts-multiword-search-and-…
PonceGL Aug 27, 2026
383012a
Merge remote-tracking branch 'origin/upstream-proposal/wear-companion…
PonceGL Aug 27, 2026
b448aaa
Merge remote-tracking branch 'origin/chore/personal-build-type-locale…
PonceGL Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ CLAUDE.md
markdown.xml
.vscode/
.agents/
/graphify-out
/.claude

# Node/tooling dependencies and local env files
**/node_modules/
Expand Down
16 changes: 15 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ android {
matchingFallbacks += listOf("release")
isDebuggable = false
}

// Release-like build for day-to-day personal use during development: same
// minification/shrinking as release (so it's representative of real
// performance), but with its own applicationId/name so it installs
// side by side with the official release build and with debug.
create("personal") {
initWith(getByName("release"))
matchingFallbacks += listOf("release")
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
isDebuggable = false
}
}

compileOptions {
Expand Down Expand Up @@ -370,7 +382,9 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.mockk)
// Android-specific artifact: plain io.mockk:mockk can't mock classes on ART
// (needs a JVM instrumentation agent that isn't available on-device).
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.worktesting)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,31 @@ class MusicDaoTest {
val titles = results.map { it.title }.sorted()
assertEquals(listOf("Cool Song", "Coolest Song Ever"), titles)
}

/**
* Regression test for a real bug found on-device: FTS4 MATCH queries built with the
* literal "AND" keyword only behave as a boolean operator on SQLite builds compiled
* with SQLITE_ENABLE_FTS3_PARENTHESIS. Without it "AND" is parsed as an ordinary
* search term, so a two-word query would only match rows that literally contained
* the word "and" - silently breaking multi-word search. This runs against the real
* on-device/emulator SQLite engine (unlike a plain string-building unit test), which
* is what actually caught the bug.
*/
@Test
@Throws(Exception::class)
fun searchSongs_multiWordQuery_matchesSongWithoutLiteralAndKeyword() = runTest {
// Insert the referenced artist/album first: songs has a foreign key to both.
musicDao.insertArtists(listOf(createArtistEntity(101L, "Some Artist")))
musicDao.insertAlbums(listOf(createAlbumEntity(201L, "Album X")))
val songs = listOf(
createSongEntity(1L, "Qué ganas de comerte", "Some Artist", "Album X", "/p1/s1.mp3"),
createSongEntity(2L, "Completely unrelated title", "Other Artist", "Album Y", "/p2/s2.mp3")
)
musicDao.insertSongs(songs)

val results = musicDao.searchSongs("que ganas", emptyList(), false).first()

assertEquals(1, results.size)
assertEquals("Qué ganas de comerte", results[0].title)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package com.theveloper.pixelplay.presentation.screens

import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.navigation.compose.rememberNavController
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.theveloper.pixelplay.R
import com.theveloper.pixelplay.data.model.Playlist
import com.theveloper.pixelplay.data.model.Song
import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel
import com.theveloper.pixelplay.presentation.viewmodel.PlaylistUiState
import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel
import com.theveloper.pixelplay.presentation.viewmodel.StablePlayerState
import com.theveloper.pixelplay.ui.theme.PixelPlayTheme
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Instrumented Compose coverage for the in-playlist search filter added to
* [PlaylistDetailScreen]. Exercises the real composable (no ViewModel is
* introduced for this feature; state lives in the composable itself) with
* relaxed mocks for [PlayerViewModel]/[PlaylistViewModel], following the
* same pattern already used for concrete-class mocking in this project's
* instrumented tests (see SyncWorkerTest).
*/
@RunWith(AndroidJUnit4::class)
class PlaylistDetailScreenSearchTest {

@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()

private val songBohemianRhapsody = buildSong(id = "song-1", title = "Bohemian Rhapsody", artist = "Queen")
private val songYesterday = buildSong(id = "song-2", title = "Yesterday", artist = "The Beatles")
private val songUnderPressure = buildSong(id = "song-3", title = "Under Pressure", artist = "Queen")
private val songImagine = buildSong(id = "song-4", title = "Imagine", artist = "John Lennon")

private val fakeSongs = listOf(songBohemianRhapsody, songYesterday, songUnderPressure, songImagine)
private val fakePlaylist = Playlist(
id = "playlist-1",
name = "Road Trip",
songIds = fakeSongs.map { it.id }
)

@Test
fun searchField_typingQuery_filtersVisibleSongs() {
setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist)

composeTestRule.onNode(hasSetTextAction()).performTextInput("queen")

composeTestRule.onNodeWithText("Bohemian Rhapsody").assertExists()
composeTestRule.onNodeWithText("Under Pressure").assertExists()
composeTestRule.onNodeWithText("Yesterday").assertDoesNotExist()
composeTestRule.onNodeWithText("Imagine").assertDoesNotExist()
}

@Test
fun searchField_typingNonMatchingQuery_showsEmptyStateWithQuery() {
setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist)

composeTestRule.onNode(hasSetTextAction()).performTextInput("zzz")

val expectedEmptyState = composeTestRule.activity.getString(R.string.search_no_results_for_query, "zzz")
composeTestRule.onNodeWithText(expectedEmptyState).assertExists()
}

@Test
fun searchField_clearingQuery_restoresFullListAndActionsRow() {
setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist)
// "Play it"/"Shuffle" are drawn by TightWrapText directly on a Canvas (no semantics
// text node), so the icon's content description is what's actually queryable here.
val playCd = composeTestRule.activity.getString(R.string.common_play)

composeTestRule.onNode(hasSetTextAction()).performTextInput("queen")
composeTestRule.onNodeWithText("Yesterday").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(playCd).assertDoesNotExist()

composeTestRule.onNode(hasSetTextAction()).performTextClearance()

composeTestRule.onNodeWithText("Yesterday").assertExists()
composeTestRule.onNodeWithContentDescription(playCd).assertExists()
}

@Test
fun actionsRow_hiddenWhileSearchQueryIsNotBlank() {
setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist)
val playCd = composeTestRule.activity.getString(R.string.common_play)
val shuffleCd = composeTestRule.activity.getString(R.string.common_shuffle)

composeTestRule.onNodeWithContentDescription(playCd).assertExists()
composeTestRule.onNodeWithContentDescription(shuffleCd).assertExists()

composeTestRule.onNode(hasSetTextAction()).performTextInput("xyz")

composeTestRule.onNodeWithContentDescription(playCd).assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(shuffleCd).assertDoesNotExist()
}

@Test
fun reorderMode_disabledAutomaticallyWhenSearchStartsAndStaysDisabledAfterClearing() {
setPlaylistDetailContent(songs = fakeSongs, playlist = fakePlaylist)
val reorderSongsCd = composeTestRule.activity.getString(R.string.playlist_cd_reorder_songs)
val reorderLabel = composeTestRule.activity.getString(R.string.playlist_action_reorder_songs)

// Only the toggle button itself exposes this content description before reorder mode is on.
assertReorderCdCount(reorderSongsCd, expectedCount = 1)

composeTestRule.onNodeWithText(reorderLabel).performClick()
composeTestRule.waitForIdle()
// Toggle button + one drag handle per visible song.
assertReorderCdCount(reorderSongsCd, expectedCount = 1 + fakeSongs.size)

composeTestRule.onNode(hasSetTextAction()).performTextInput("queen")
composeTestRule.waitForIdle()
// Actions row (and its toggle button) is hidden entirely while filtering.
assertReorderCdCount(reorderSongsCd, expectedCount = 0)

composeTestRule.onNode(hasSetTextAction()).performTextClearance()
composeTestRule.waitForIdle()
// Reorder mode was force-disabled by the LaunchedEffect, not just hidden: only the
// toggle button reappears, the drag handles do not come back on their own.
assertReorderCdCount(reorderSongsCd, expectedCount = 1)
}

@Test
fun clickingFilteredSong_playsFullPlaylistFromClickedSong() {
val playerViewModel = mockPlayerViewModel()
val playlistViewModel = mockPlaylistViewModel(fakeSongs, fakePlaylist)
setPlaylistDetailContent(playerViewModel = playerViewModel, playlistViewModel = playlistViewModel)

// Filter down to a single song that isn't the first in the playlist.
composeTestRule.onNode(hasSetTextAction()).performTextInput("yesterday")
composeTestRule.onNodeWithText("Yesterday").performClick()

verify(exactly = 1) {
playerViewModel.playSongs(fakeSongs, songYesterday, fakePlaylist.name, fakePlaylist.id)
}
}

@Test
fun emptyPlaylist_doesNotShowSearchField() {
setPlaylistDetailContent(songs = emptyList(), playlist = fakePlaylist)

val searchLabel = composeTestRule.activity.getString(R.string.song_picker_search_label)
composeTestRule.onNodeWithText(searchLabel).assertDoesNotExist()
}

private fun assertReorderCdCount(contentDescription: String, expectedCount: Int) {
val actualCount = composeTestRule
.onAllNodesWithContentDescription(contentDescription)
.fetchSemanticsNodes()
.size
assert(actualCount == expectedCount) {
"Expected $expectedCount node(s) with content description \"$contentDescription\", found $actualCount"
}
}

private fun setPlaylistDetailContent(
songs: List<Song>,
playlist: Playlist,
playerViewModel: PlayerViewModel = mockPlayerViewModel(),
playlistViewModel: PlaylistViewModel = mockPlaylistViewModel(songs, playlist)
) {
setPlaylistDetailContent(playerViewModel = playerViewModel, playlistViewModel = playlistViewModel)
}

private fun setPlaylistDetailContent(
playerViewModel: PlayerViewModel,
playlistViewModel: PlaylistViewModel
) {
composeTestRule.setContent {
PixelPlayTheme {
PlaylistDetailScreen(
playlistId = fakePlaylist.id,
onBackClick = {},
onDeletePlayListClick = {},
playerViewModel = playerViewModel,
playlistViewModel = playlistViewModel,
navController = rememberNavController()
)
}
}
}

private fun mockPlaylistViewModel(songs: List<Song>, playlist: Playlist): PlaylistViewModel {
val viewModel = mockk<PlaylistViewModel>(relaxed = true)
every { viewModel.uiState } returns MutableStateFlow(
PlaylistUiState(currentPlaylistDetails = playlist, currentPlaylistSongs = songs)
)
return viewModel
}

private fun mockPlayerViewModel(): PlayerViewModel {
val viewModel = mockk<PlayerViewModel>(relaxed = true)
every { viewModel.stablePlayerState } returns MutableStateFlow(StablePlayerState())
every { viewModel.selectedSongForInfo } returns MutableStateFlow(null)
every { viewModel.favoriteSongIds } returns MutableStateFlow(emptySet())
every { viewModel.navBarCompactMode } returns MutableStateFlow(false)
every { viewModel.isSortingSheetVisible } returns MutableStateFlow(false)
return viewModel
}

private fun buildSong(
id: String,
title: String,
artist: String,
album: String = "Album"
): Song = Song(
id = id,
title = title,
artist = artist,
artistId = 1L,
album = album,
albumId = 1L,
path = "/tmp/$id.mp3",
contentUriString = "content://pixelplay/song/$id",
albumArtUriString = null,
duration = 180_000L,
mimeType = "audio/mpeg",
bitrate = 320_000,
sampleRate = 44_100
)
}
4 changes: 4 additions & 0 deletions app/src/debug/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PixelPlayer [D]</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/debug/res/values-es/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PixelPlayer [D]</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/debug/res/values-in/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PixelPlayer [D]</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/debug/res/values-it/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PixelPlayer [D]</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/debug/res/values-tr/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PixelPlayer [D]</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/debug/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PixelPlayer [D]</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@
android:scheme="wear"
android:host="*"
android:pathPrefix="/watch_library_state" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/playlist_sync_ack" />
</intent-filter>
</service>

Expand Down
Loading