Compare commits

...

6 commits

25 changed files with 729 additions and 110 deletions

BIN
Satsamp Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

View file

@ -54,6 +54,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }

View file

@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- In-app self-update: launching the package installer for a downloaded APK. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- NIP-55 external signer discovery (Amber, etc.) --> <!-- NIP-55 external signer discovery (Amber, etc.) -->
@ -48,6 +50,17 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- Shares update APKs from cacheDir/apks to the package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -465,11 +465,18 @@ class PlaybackManager(
} }
private fun startIcyReader(url: String) { private fun startIcyReader(url: String) {
// Never leave a previous reader's socket open: an unclosed stream
// keeps its OkHttp connection (and its own thread) streaming forever,
// and every ghost feeds the same now-playing field — that is how four
// different songs flickered over one another.
stopIcyReader()
val reader = IcyStreamReader() val reader = IcyStreamReader()
icyReader = reader icyReader = reader
reader.listener = object : IcyStreamReader.Listener { reader.listener = object : IcyStreamReader.Listener {
override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) { override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) {
// Drop callbacks from a reader we already replaced or stopped.
if (icyReader !== reader) return
Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata") Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata")
// Record at the reader-reported ABSOLUTE audio position (the same // Record at the reader-reported ABSOLUTE audio position (the same
// quantity as audioBuffer.totalBytes). The previous code read // quantity as audioBuffer.totalBytes). The previous code read
@ -486,6 +493,7 @@ class PlaybackManager(
} }
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) { override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
if (icyReader !== reader) return
audioBuffer?.write(data, offset, length.toInt()) audioBuffer?.write(data, offset, length.toInt())
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0) listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
@ -495,6 +503,7 @@ class PlaybackManager(
} }
override fun onError(error: String) { override fun onError(error: String) {
if (icyReader !== reader) return
Log.w(TAG, "ICY reader error: $error") Log.w(TAG, "ICY reader error: $error")
// ExoPlayer often rides out a transient drop on its own // ExoPlayer often rides out a transient drop on its own
// buffer without erroring; if it does, the recorder would // buffer without erroring; if it does, the recorder would

View file

@ -18,6 +18,12 @@ class IcyStreamReader(
var isRunning: Boolean = false var isRunning: Boolean = false
private set private set
/** True once stop() was called by request. A close() racing a blocked
* stream.read() throws IOException, NOT CancellationException — without
* this flag the generic catch reports an intentional stop as an error,
* and the manager's error-restart resurrects a ghost reader. */
@Volatile private var requestedStop = false
private var metaInterval: Int = 0 private var metaInterval: Int = 0
private var bytesInInterval: Int = 0 private var bytesInInterval: Int = 0
private var pendingMetaBytes: Int = 0 private var pendingMetaBytes: Int = 0
@ -52,6 +58,7 @@ class IcyStreamReader(
fun start(streamUrl: String) { fun start(streamUrl: String) {
if (isRunning) stop() if (isRunning) stop()
requestedStop = false
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope?.launch { scope?.launch {
try { try {
@ -59,8 +66,10 @@ class IcyStreamReader(
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Normal stop // Normal stop
} catch (e: Exception) { } catch (e: Exception) {
if (!requestedStop) {
Log.e(TAG, "Error reading stream", e) Log.e(TAG, "Error reading stream", e)
listener?.onError(e.message ?: "Connection error") listener?.onError(e.message ?: "Connection error")
}
} finally { } finally {
isRunning = false isRunning = false
} }
@ -68,6 +77,7 @@ class IcyStreamReader(
} }
fun stop() { fun stop() {
requestedStop = true
scope?.cancel() scope?.cancel()
scope = null scope = null
closeConnection() closeConnection()

View file

@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -61,6 +62,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.wavlake.MatchingMode import com.rada.data.wavlake.MatchingMode
import com.rada.data.wavlake.WavlakeSearchHit import com.rada.data.wavlake.WavlakeSearchHit
@ -204,6 +206,25 @@ fun DiscoverScreen(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) )
// What to search: toggle Artists / Tracks / Genres / Stations.
val selectedTypes by viewModel.selectedTypes.collectAsState()
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp,
),
) {
items(DiscoverType.entries.toList(), key = { "type-${it.name}" }) { type ->
GenreChip(
label = type.label,
selected = type in selectedTypes,
onClick = { viewModel.toggleType(type) },
)
}
}
Spacer(modifier = Modifier.height(6.dp))
when (val s = searchState) { when (val s = searchState) {
WavlakeSearchState.Idle -> { WavlakeSearchState.Idle -> {
GenreChips( GenreChips(
@ -247,33 +268,142 @@ fun DiscoverScreen(
} }
} }
is WavlakeSearchState.Results -> { is WavlakeSearchState.Results -> {
val r = s.results
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit -> if (r.stations.isNotEmpty()) {
when (hit) { item(key = "sec-stations", contentType = "header") {
is WavlakeSearchHit.Artist -> ArtistRow( SectionHeader("Stations")
name = hit.artist.name, }
genre = hit.artist.genre, items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
imageUrl = hit.artist.imageUrl, Card(
artistId = hit.artist.id, onClick = { viewModel.playFoundStation(st) },
address = addresses[hit.artist.id], modifier = Modifier
onClick = { .fillMaxWidth()
viewModel.openArtist(hit.artist.id, hit.artist.name) .padding(horizontal = 12.dp),
}, colors = CardDefaults.cardColors(
containerColor = LocalTokens.current.glassFillStrong,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
LocalTokens.current.accentFill.copy(alpha = 0.15f),
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = LocalTokens.current.accentText,
modifier = Modifier.size(20.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = st.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = st.genre.ifBlank { "Radio" },
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
maxLines = 1,
)
}
IconButton(
onClick = { viewModel.playFoundStation(st) },
modifier = Modifier.size(48.dp),
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play ${st.name}",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
}
if (r.genres.isNotEmpty()) {
item(key = "sec-genres", contentType = "header") {
SectionHeader("Genres")
}
items(r.genres, key = { "gen-$it" }, contentType = { "genre" }) { genre ->
Card(
onClick = { viewModel.openGenre(genre) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = LocalTokens.current.glassFillStrong,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = genre,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = LocalTokens.current.text,
modifier = Modifier.weight(1f),
)
Text(
text = "Browse",
style = MaterialTheme.typography.labelLarge,
color = LocalTokens.current.accentText,
)
}
}
}
}
if (r.artists.isNotEmpty()) {
item(key = "sec-artists", contentType = "header") {
SectionHeader("Artists")
}
items(r.artists, key = { "artist-${it.id}" }, contentType = { "artist" }) { artist ->
ArtistRow(
name = artist.name,
genre = artist.genre,
imageUrl = artist.imageUrl,
artistId = artist.id,
address = addresses[artist.id],
onClick = { viewModel.openArtist(artist.id, artist.name) },
onZap = { onZap = {
viewModel.resolveArtistAddress(hit.artist.id) viewModel.resolveArtistAddress(artist.id)
pendingZapArtistId = hit.artist.id pendingZapArtistId = artist.id
}, },
) )
is WavlakeSearchHit.Album -> AlbumRow( }
title = hit.title, }
artist = hit.artist, if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
artworkUrl = hit.artworkUrl, item(key = "sec-tracks", contentType = "header") {
onClick = { /* albums open via artist detail */ }, SectionHeader("Tracks")
) }
is WavlakeSearchHit.Track -> TrackRow( items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
TrackRow(
track = com.rada.domain.model.WavlakeTrack( track = com.rada.domain.model.WavlakeTrack(
id = hit.track.id, id = hit.track.id,
title = hit.track.title, title = hit.track.title,
@ -317,6 +447,14 @@ fun DiscoverScreen(
}, },
) )
} }
items(r.albums, key = { "album-${it.id}" }, contentType = { "album" }) { album ->
AlbumRow(
title = album.title,
artist = album.artist,
artworkUrl = album.artworkUrl,
onClick = { /* albums open via artist detail */ },
)
}
} }
} }
} }
@ -334,6 +472,18 @@ fun DiscoverScreen(
} }
} }
@Composable
private fun SectionHeader(title: String) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
color = LocalTokens.current.accentText,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 2.dp),
)
}
private data class ZapTarget( private data class ZapTarget(
val artistId: String, val artistId: String,
val artistName: String, val artistName: String,

View file

@ -26,10 +26,27 @@ import javax.inject.Inject
/** Curated genre shortcuts — exact Wavlake catalog names only. */ /** Curated genre shortcuts — exact Wavlake catalog names only. */
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock") val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
/** What the Discover search looks for; user-toggled chips. */
enum class DiscoverType(val label: String) {
ARTISTS("Artists"), TRACKS("Tracks"), GENRES("Genres"), STATIONS("Stations"),
}
/** Search results bucketed by type — only selected types are populated. */
data class DiscoverResults(
val artists: List<WavlakeArtist> = emptyList(),
val tracks: List<WavlakeSearchHit.Track> = emptyList(),
val albums: List<WavlakeSearchHit.Album> = emptyList(),
val genres: List<String> = emptyList(),
val stations: List<com.rada.domain.model.Station> = emptyList(),
) {
fun isEmpty() = artists.isEmpty() && tracks.isEmpty() && albums.isEmpty() &&
genres.isEmpty() && stations.isEmpty()
}
sealed interface WavlakeSearchState { sealed interface WavlakeSearchState {
data object Idle : WavlakeSearchState data object Idle : WavlakeSearchState
data object Loading : WavlakeSearchState data object Loading : WavlakeSearchState
data class Results(val hits: List<WavlakeSearchHit>) : WavlakeSearchState data class Results(val results: DiscoverResults) : WavlakeSearchState
data class Error(val message: String) : WavlakeSearchState data class Error(val message: String) : WavlakeSearchState
} }
@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor(
private val repository: WavlakeRepository, private val repository: WavlakeRepository,
private val playbackManager: PlaybackManager, private val playbackManager: PlaybackManager,
private val playlists: PlaylistRepository, private val playlists: PlaylistRepository,
private val stations: com.rada.data.repository.StationRepository,
@dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
/** Which result types to search/show; persisted so the choice sticks. */
private val _selectedTypes = MutableStateFlow(loadTypes())
val selectedTypes: StateFlow<Set<DiscoverType>> = _selectedTypes.asStateFlow()
fun toggleType(type: DiscoverType) {
val current = _selectedTypes.value.toMutableSet()
if (type in current) {
// Never allow an empty selection — re-tapping the last one keeps it.
if (current.size == 1) return
current -= type
} else current += type
_selectedTypes.value = current
appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE)
.edit().putStringSet("types", current.map { it.name }.toSet()).apply()
// Re-run the current query so results reflect the new selection now.
val q = _searchQuery.value
if (q.trim().length >= 2) _searchQuery.value = "$q "
}
private fun loadTypes(): Set<DiscoverType> {
val saved = appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE)
.getStringSet("types", null)
val parsed = saved?.mapNotNull { runCatching { DiscoverType.valueOf(it) }.getOrNull() }?.toSet()
return parsed?.takeIf { it.isNotEmpty() } ?: DiscoverType.entries.toSet()
}
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle) private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow() val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor(
return@onEach return@onEach
} }
_searchState.value = WavlakeSearchState.Loading _searchState.value = WavlakeSearchState.Loading
_searchState.value = when (val r = repository.search(query)) { val types = _selectedTypes.value
is WavlakeResult.Ok -> // Local stations need no network; Wavlake hit types share one call.
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.") val needRemote = types.any {
else WavlakeSearchState.Results(r.value) it != DiscoverType.GENRES && it != DiscoverType.STATIONS
is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
} }
val hits = if (needRemote) {
when (val r = repository.search(query)) {
is WavlakeResult.Ok -> r.value
is WavlakeResult.Failed -> {
// Stations alone can still answer; otherwise surface the error.
if (types.contains(DiscoverType.STATIONS)) emptyList()
else {
_searchState.value = WavlakeSearchState.Error(r.message)
return@onEach
}
}
}
} else emptyList()
val matchQ = query.lowercase()
val bucket = DiscoverResults(
artists = hits.filterIsInstance<WavlakeSearchHit.Artist>()
.filter { DiscoverType.ARTISTS in types }.map { it.artist },
tracks = hits.filterIsInstance<WavlakeSearchHit.Track>()
.filter { DiscoverType.TRACKS in types },
albums = hits.filterIsInstance<WavlakeSearchHit.Album>()
.filter { DiscoverType.TRACKS in types },
genres = (if (DiscoverType.GENRES in types) genreMatches(query) else emptyList()),
stations = if (DiscoverType.STATIONS in types)
stations.getAll().filter {
it.name.lowercase().contains(matchQ) ||
it.genre.lowercase().contains(matchQ)
}
else emptyList(),
)
_searchState.value =
if (bucket.isEmpty()) WavlakeSearchState.Error("No results for “$query”.")
else WavlakeSearchState.Results(bucket)
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
refreshRankings(null) refreshRankings(null)
} }
/** Genres matching the query: curated discover genres + every station genre. */
private fun genreMatches(query: String): List<String> {
val q = query.lowercase()
return (DISCOVER_GENRES + stations.getAll().map { it.genre }.filter { it.isNotBlank() })
.map { it to it.lowercase() }
.filter { (_, low) -> low.contains(q) || q.contains(low.substringBefore('/')) }
.map { it.first }
.distinct()
.take(8)
}
fun search(query: String) { fun search(query: String) {
_searchQuery.value = query _searchQuery.value = query
} }
@ -220,6 +308,31 @@ class WavlakeViewModel @Inject constructor(
) )
} }
/** Start a local/added radio station found through Discover search. */
fun playFoundStation(station: com.rada.domain.model.Station) {
playbackManager.playStation(station.url, station.name)
}
/** Jump the Stations tab filtered to this genre (Discover genre hit). */
fun openGenre(genre: String) {
_rankingsGenre.value = genre
viewModelScope.launch {
_rankingsLoading.value = true
_rankingsError.value = null
when (val r = repository.rankings(days = 7, genre = genre, limit = 25)) {
is WavlakeResult.Ok -> {
_rankings.value = r.value
resolveAddresses(r.value)
}
is WavlakeResult.Failed -> {
_rankings.value = emptyList()
_rankingsError.value = r.message
}
}
_rankingsLoading.value = false
}
}
fun saveTrack(track: WavlakeTrack) { fun saveTrack(track: WavlakeTrack) {
viewModelScope.launch { viewModelScope.launch {
_notice.value = when (val r = repository.importTrack(track)) { _notice.value = when (val r = repository.importTrack(track)) {

View file

@ -1,5 +1,6 @@
package com.rada.ui.library package com.rada.ui.library
import androidx.compose.animation.Crossfade
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -28,6 +29,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.MusicNote
@ -107,6 +109,7 @@ fun LibraryScreen(
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var showImportMenu by remember { mutableStateOf(false) } var showImportMenu by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) }
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) } var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
var songForPlaylist by remember { mutableStateOf<SavedSongEntity?>(null) } var songForPlaylist by remember { mutableStateOf<SavedSongEntity?>(null) }
@ -154,7 +157,14 @@ fun LibraryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Library") }, title = {
Text(
"Library",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
)
},
navigationIcon = { navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) { Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome) AppHomeButton(onClick = onHome)
@ -197,43 +207,73 @@ fun LibraryScreen(
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play") Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
} }
} }
IconButton( // Refresh / delete-folder / multi-select live in one
onClick = { viewModel.refreshImportedFolders() }, // overflow menu so the title keeps its full width instead
enabled = !uiState.isImporting, // of wrapping mid-word ("Libr|ary"). All still reachable.
) { Box {
IconButton(onClick = { showMoreMenu = true }) {
Icon( Icon(
Icons.Default.Refresh, Icons.Default.MoreVert,
contentDescription = "Refresh imported folders", contentDescription = "More library actions",
tint = if (uiState.isImporting) { tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
LocalTokens.current.textSecondary.copy(alpha = 0.4f) else LocalTokens.current.textSecondary,
} else { )
LocalTokens.current.textSecondary }
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false },
) {
DropdownMenuItem(
text = { Text("Refresh imported folders") },
enabled = !uiState.isImporting,
onClick = {
showMoreMenu = false
viewModel.refreshImportedFolders()
},
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
)
if (uiState.selectedPlaylistId != null) {
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }
?.let { selected ->
DropdownMenuItem(
text = { Text("Delete “${selected.name}”") },
onClick = {
showMoreMenu = false
playlistToDelete = selected
},
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}, },
) )
} }
// Reliable delete path for the selected folder (besides long-press).
if (uiState.selectedPlaylistId != null) {
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected ->
IconButton(onClick = { playlistToDelete = selected }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete folder",
tint = LocalTokens.current.textSecondary,
)
} }
}
}
// Multi-select: combine several folders, shuffle the union.
if (tabLabels.size > 1) { if (tabLabels.size > 1) {
IconButton(onClick = { DropdownMenuItem(
text = {
Text(
if (uiState.isMultiSelect) "Exit multi-select"
else "Select multiple folders",
)
},
onClick = {
showMoreMenu = false
if (uiState.isMultiSelect) viewModel.exitMultiSelect() if (uiState.isMultiSelect) viewModel.exitMultiSelect()
else viewModel.enterMultiSelect() else viewModel.enterMultiSelect()
}) { },
leadingIcon = {
Icon( Icon(
Icons.Default.Checklist, Icons.Default.Checklist,
contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists", contentDescription = null,
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
else LocalTokens.current.textSecondary,
) )
},
)
}
} }
} }
IconButton(onClick = { showAppearance = true }) { IconButton(onClick = { showAppearance = true }) {
@ -284,16 +324,20 @@ fun LibraryScreen(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 2.dp), .padding(top = 4.dp, bottom = 2.dp),
) { ) {
// Crossfade the header when the selected folder changes — a
// hard text swap reads as a glitch when tabs are tapped.
Crossfade(targetState = headerTitle, label = "libraryHeader") { t ->
Text( Text(
text = headerTitle, text = t,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = LocalTokens.current.text, color = LocalTokens.current.text,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}
Text( Text(
text = "${uiState.songs.size} songs", text = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary, color = LocalTokens.current.textSecondary,
) )
@ -477,8 +521,12 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(uiState.songs, key = { it.id }) { song -> items(uiState.songs, key = { it.id }) { song ->
// Keyed rows animate in/out when the folder tab or
// search filter changes instead of popping.
val rowMod = if (LocalReduceMotion.current) Modifier else Modifier.animateItem()
SongListItem( SongListItem(
song = song, song = song,
modifier = rowMod,
isPlaying = song.id == uiState.currentPlayingId, isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) }, onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) }, onToggleFavorite = { viewModel.toggleFavorite(song.id) },
@ -768,10 +816,11 @@ private fun SongListItem(
onToggleFavorite: () -> Unit, onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit, onAddToPlaylist: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.pressDip(interaction, LocalReduceMotion.current) .pressDip(interaction, LocalReduceMotion.current)
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick) .clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)

View file

@ -30,6 +30,7 @@ import com.rada.ui.player.NowPlayingBar
import com.rada.ui.playlists.PlaylistManagerScreen import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen import com.rada.ui.streams.StreamsScreen
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.LocalTokens import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,16 +50,28 @@ fun TabsScreen(
) { ) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 } val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val reduceMotion = LocalReduceMotion.current
// Deep-link target for Library (playlist picker → open that playlist). // Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) } var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Tab taps land immediately. Swiping remains animated, but animating a // Adjacent tabs glide (neighbors stay composed, so it's smooth); far
// tap composes and disposes heavy pages while the gesture is in flight. // taps jump instantly — animating a multi-page hop composes heavy pages
// mid-flight and reintroduces the jank the jump was there to avoid.
fun goToPage(page: Int) { fun goToPage(page: Int) {
if (pagerState.currentPage == page) return if (pagerState.currentPage == page) return
scope.launch { scope.launch {
if (kotlin.math.abs(pagerState.currentPage - page) == 1 && !reduceMotion) {
pagerState.animateScrollToPage(
page,
animationSpec = androidx.compose.animation.core.tween(
durationMillis = 260,
easing = com.rada.ui.theme.MotionArrival,
),
)
} else {
pagerState.scrollToPage(page) pagerState.scrollToPage(page)
} }
} }
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,

View file

@ -84,6 +84,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -440,41 +441,49 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd) val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
val accentGlow = LocalTokens.current.accentFill val accentGlow = LocalTokens.current.accentFill
Canvas(modifier = modifier) { // Build each wave path + its gradient ONCE per size/palette, not per draw
// pass: this canvas sits on the hub, and rebuilding ~1k lineTo segments
// per layer was the heavy frame behind the hub-transition jank (99th-p
// ~200ms). 4px steps are indistinguishable at this frequency and cut
// segment count by 4x.
var canvasSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
val layers = remember(canvasSize, gradientColors) {
val w = canvasSize.width.toFloat()
val h = canvasSize.height.toFloat()
if (w <= 0f || h <= 0f) emptyList()
else (0..2).map { layer ->
val amplitude = (30f - layer * 8f) * (w / 400f)
val frequency = 0.008f + layer * 0.002f
val phaseShift = layer * 0.5f
val path = Path().apply {
moveTo(0f, h / 2 + amplitude * kotlin.math.sin(phaseShift.toDouble()).toFloat())
var x = 0f
while (x <= w) {
val y = h / 2 + amplitude * kotlin.math.sin(
(x * frequency * Math.PI * 2).toDouble() + phaseShift
).toFloat()
lineTo(x, y)
x += 4f
}
}
val strokeBrush = Brush.horizontalGradient(
colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) },
startX = 0f,
endX = w,
)
Triple(path, strokeBrush, (3f - layer * 0.5f) * (w / 400f))
}
}
Canvas(
modifier = modifier.onSizeChanged { canvasSize = it },
) {
val width = size.width val width = size.width
val height = size.height val height = size.height
val centerY = height / 2 val centerY = height / 2
// Draw multiple wave layers for ((path, strokeBrush, strokeWidth) in layers) {
for (layer in 0..2) { drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth))
val path = Path()
val amplitude = (30f - layer * 8f) * (width / 400f)
val frequency = 0.008f + layer * 0.002f
val phaseShift = layer * 0.5f
path.moveTo(0f, centerY)
for (x in 0..width.toInt()) {
val xFloat = x.toFloat()
val y = centerY + amplitude * kotlin.math.sin(
(xFloat * frequency * Math.PI * 2).toDouble() + phaseShift
).toFloat()
if (x == 0) {
path.moveTo(xFloat, y)
} else {
path.lineTo(xFloat, y)
}
}
drawPath(
path = path,
brush = Brush.horizontalGradient(
colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) },
startX = 0f,
endX = width,
),
style = Stroke(width = (3f - layer * 0.5f) * (width / 400f)),
)
} }
// Ambient glow behind the waves — wide, faint, no visible disc edge. // Ambient glow behind the waves — wide, faint, no visible disc edge.

View file

@ -1,5 +1,8 @@
package com.rada.ui.theme package com.rada.ui.theme
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -18,10 +21,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -30,6 +40,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rada.BuildConfig
import com.rada.update.UpdateChecker
import kotlinx.coroutines.launch
/** Static swatch preview colors for the picker (independent of active theme). */ /** Static swatch preview colors for the picker (independent of active theme). */
internal val AccentSwatches = mapOf( internal val AccentSwatches = mapOf(
@ -52,6 +65,46 @@ fun AppearanceDialog(
val context = LocalContext.current val context = LocalContext.current
val tokens = LocalTokens.current val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark() val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
val scope = rememberCoroutineScope()
// Update flow state: idle | checking | downloading(progress) | ready | error | upToDate
var updateStatus by remember { mutableStateOf<String?>(null) }
var updateProgress by remember { mutableFloatStateOf(-1f) }
var updateInfo by remember { mutableStateOf<UpdateChecker.UpdateInfo?>(null) }
fun startUpdate() {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
updateStatus = "needs_permission"
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { updateStatus = "Can't open install settings on this device." }
return
}
updateStatus = "checking"
scope.launch {
try {
val info = UpdateChecker.check(BuildConfig.VERSION_NAME)
if (info == null) {
updateStatus = "up_to_date"
} else {
updateInfo = info
updateStatus = "downloading"
updateProgress = -1f
val apk = UpdateChecker.download(context, info.downloadUrl) { f ->
updateProgress = f
}
updateStatus = "ready"
UpdateChecker.launchInstall(context, apk)
}
} catch (e: Exception) {
updateStatus = "error: ${e.message ?: "download failed"}"
}
}
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -141,6 +194,52 @@ fun AppearanceDialog(
fontSize = 12.sp, fontSize = 12.sp,
color = tokens.textMuted, color = tokens.textMuted,
) )
Spacer(Modifier.height(22.dp))
Text(
"UPDATE",
fontSize = 12.sp,
letterSpacing = 0.4.sp,
fontWeight = FontWeight.SemiBold,
color = tokens.textMuted,
)
Spacer(Modifier.height(10.dp))
val busy = updateStatus == "checking" || updateStatus == "downloading"
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(SatsAmpGlass.rInput))
.background(tokens.accentSoft)
.border(1.dp, tokens.borderInteractive, RoundedCornerShape(SatsAmpGlass.rInput))
.clickable(enabled = !busy) { startUpdate() }
.padding(vertical = 12.dp, horizontal = 14.dp),
) {
Column {
Text(
when (updateStatus) {
"checking" -> "Checking for updates…"
"downloading" -> if (updateProgress >= 0f)
"Downloading v${updateInfo?.version}… ${(updateProgress * 100).toInt()}%"
else
"Downloading v${updateInfo?.version}…"
"ready" -> "Update downloaded — install again if the dialog was dismissed"
"up_to_date" -> "You're up to date (v${BuildConfig.VERSION_NAME})"
"needs_permission" -> "Allow \"Install unknown apps\" for SatsAmp, then tap again"
null -> "Check for updates (current: v${BuildConfig.VERSION_NAME})"
else -> updateStatus ?: ""
},
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = tokens.text,
)
if (updateStatus == "downloading" && updateProgress >= 0f) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { updateProgress },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
} }
}, },
confirmButton = { confirmButton = {

View file

@ -1,6 +1,7 @@
package com.rada.ui.theme package com.rada.ui.theme
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
@ -130,12 +131,15 @@ fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
/** /**
* Press-dip for list rows: scales to 0.97 while pressed. Use on rows that * Press-dip for list rows: scales to 0.97 while pressed. Use on rows that
* already have a clickable/combinedClickable modifier earlier in the chain. * already have a clickable/combinedClickable modifier earlier in the chain.
* (A plain @Composable extension — the old composed{} form spun up an extra
* subcomposition per row, wasted recomposition churn in long lists.)
*/ */
@Composable
fun Modifier.pressDip( fun Modifier.pressDip(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
reduce: Boolean, reduce: Boolean,
): Modifier = composed { ): Modifier {
if (reduce) return@composed this if (reduce) return this
val pressed by interactionSource.collectIsPressedAsState() val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f, targetValue = if (pressed) 0.97f else 1f,
@ -145,7 +149,7 @@ fun Modifier.pressDip(
), ),
label = "pressDip", label = "pressDip",
) )
this.graphicsLayer { scaleX = scale; scaleY = scale } return this.graphicsLayer { scaleX = scale; scaleY = scale }
} }
// ── Dismiss-by-drag ─────────────────────────────────────────────────────── // ── Dismiss-by-drag ───────────────────────────────────────────────────────

View file

@ -0,0 +1,121 @@
package com.rada.update
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Self-update over the Forgejo release API (git.atitlan.io/avi/SatsAmp).
* Reads the latest public release anonymously, downloads the APK asset into
* the app cache, then hands it to the system installer via FileProvider —
* the user only needs to confirm the install dialog (and allow
* "install unknown apps" once).
*/
object UpdateChecker {
const val RELEASE_PAGE_URL = "https://git.atitlan.io/avi/SatsAmp/releases"
private const val LATEST_API =
"https://git.atitlan.io/api/v1/repos/avi/SatsAmp/releases/latest"
private val json = Json { ignoreUnknownKeys = true }
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
@Serializable
private data class ReleaseJson(
val tag_name: String = "",
val assets: List<AssetJson> = emptyList(),
)
@Serializable
private data class AssetJson(
val name: String = "",
val size: Long = 0,
val browser_download_url: String = "",
)
data class UpdateInfo(val version: String, val downloadUrl: String, val sizeBytes: Long)
/** Returns UpdateInfo when the latest release is newer than this build, else null. */
suspend fun check(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
val req = Request.Builder().url(LATEST_API)
.header("Accept", "application/json").build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null // no releases yet / offline
val body = resp.body?.string() ?: return@withContext null
val rel = json.decodeFromString(ReleaseJson.serializer(), body)
val apk = rel.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
?: return@withContext null
val v = normalize(rel.tag_name)
if (v.isNotEmpty() && compareVersions(v, normalize(currentVersion)) > 0)
UpdateInfo(v, apk.browser_download_url, apk.size)
else null
}
}
/** Downloads the APK; [onProgress] gets fraction 0..1 (or -1 if size unknown). */
suspend fun download(
context: Context,
url: String,
onProgress: (Float) -> Unit,
): File = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "apks").apply { mkdirs() }
// Clear stale downloads so a partial/old file can't shadow the new one.
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "update.apk")
val req = Request.Builder().url(url).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw java.io.IOException("Download failed: HTTP ${resp.code}")
val stream = resp.body?.byteStream() ?: throw java.io.IOException("Empty body")
val total = resp.body?.contentLength() ?: -1L
stream.use { input ->
out.outputStream().use { os ->
val buf = ByteArray(64 * 1024)
var done = 0L
while (true) {
val n = input.read(buf)
if (n < 0) break
os.write(buf, 0, n)
done += n
onProgress(if (total > 0) done.toFloat() / total else -1f)
}
}
}
}
out
}
/** Launches the system package installer for the downloaded APK. */
fun launchInstall(context: Context, apk: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/** "v0.1.2" / "0.1.2" -> "0.1.2" */
private fun normalize(v: String): String =
v.trim().removePrefix("v").removePrefix("V").substringBefore('-')
/** Component-wise numeric compare; missing components count as 0. */
internal fun compareVersions(a: String, b: String): Int {
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(pa.size, pb.size)) {
val d = (pa.getOrNull(i) ?: 0) - (pb.getOrNull(i) ?: 0)
if (d != 0) return if (d > 0) 1 else -1
}
return 0
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 B

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Update APKs downloaded into context.cacheDir/apks -->
<cache-path name="apk" path="apks/" />
</paths>

View file

@ -0,0 +1,23 @@
package com.rada.update
import org.junit.Assert.assertEquals
import org.junit.Test
class UpdateCheckerTest {
@Test fun `newer release detected`() {
assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1.0"))
assertEquals(1, UpdateChecker.compareVersions("0.2.0", "0.1.9"))
assertEquals(1, UpdateChecker.compareVersions("1.0.0", "0.9.9"))
}
@Test fun `same and older are not updates`() {
assertEquals(0, UpdateChecker.compareVersions("0.1.0", "0.1.0"))
assertEquals(-1, UpdateChecker.compareVersions("0.1.0", "0.1.1"))
assertEquals(-1, UpdateChecker.compareVersions("0.9", "0.10"))
}
@Test fun `missing components count as zero`() {
assertEquals(0, UpdateChecker.compareVersions("0.1", "0.1.0"))
assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1"))
}
}