diff --git a/Satsamp Icon.png b/Satsamp Icon.png
new file mode 100644
index 0000000..4237cb2
Binary files /dev/null and b/Satsamp Icon.png differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ab2502d..957223e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,6 +54,7 @@ android {
buildFeatures {
compose = true
+ buildConfig = true
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b61c366..b5fe611 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,8 @@
+
+
@@ -48,6 +50,17 @@
+
+
+
+
+
diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt
index d656776..1bacbd6 100644
--- a/app/src/main/java/com/rada/player/PlaybackManager.kt
+++ b/app/src/main/java/com/rada/player/PlaybackManager.kt
@@ -465,11 +465,18 @@ class PlaybackManager(
}
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()
icyReader = reader
reader.listener = object : IcyStreamReader.Listener {
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")
// Record at the reader-reported ABSOLUTE audio position (the same
// quantity as audioBuffer.totalBytes). The previous code read
@@ -486,6 +493,7 @@ class PlaybackManager(
}
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
+ if (icyReader !== reader) return
audioBuffer?.write(data, offset, length.toInt())
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
@@ -495,6 +503,7 @@ class PlaybackManager(
}
override fun onError(error: String) {
+ if (icyReader !== reader) return
Log.w(TAG, "ICY reader error: $error")
// ExoPlayer often rides out a transient drop on its own
// buffer without erroring; if it does, the recorder would
diff --git a/app/src/main/java/com/rada/recording/IcyStreamReader.kt b/app/src/main/java/com/rada/recording/IcyStreamReader.kt
index 989e393..5c3e899 100644
--- a/app/src/main/java/com/rada/recording/IcyStreamReader.kt
+++ b/app/src/main/java/com/rada/recording/IcyStreamReader.kt
@@ -18,6 +18,12 @@ class IcyStreamReader(
var isRunning: Boolean = false
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 bytesInInterval: Int = 0
private var pendingMetaBytes: Int = 0
@@ -52,6 +58,7 @@ class IcyStreamReader(
fun start(streamUrl: String) {
if (isRunning) stop()
+ requestedStop = false
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope?.launch {
try {
@@ -59,8 +66,10 @@ class IcyStreamReader(
} catch (e: CancellationException) {
// Normal stop
} catch (e: Exception) {
- Log.e(TAG, "Error reading stream", e)
- listener?.onError(e.message ?: "Connection error")
+ if (!requestedStop) {
+ Log.e(TAG, "Error reading stream", e)
+ listener?.onError(e.message ?: "Connection error")
+ }
} finally {
isRunning = false
}
@@ -68,6 +77,7 @@ class IcyStreamReader(
}
fun stop() {
+ requestedStop = true
scope?.cancel()
scope = null
closeConnection()
diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
index a137c94..6a5d856 100644
--- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
+++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
@@ -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.Bolt
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.Tune
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.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.wavlake.MatchingMode
import com.rada.data.wavlake.WavlakeSearchHit
@@ -204,6 +206,25 @@ fun DiscoverScreen(
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) {
WavlakeSearchState.Idle -> {
GenreChips(
@@ -247,33 +268,142 @@ fun DiscoverScreen(
}
}
is WavlakeSearchState.Results -> {
+ val r = s.results
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
- items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit ->
- when (hit) {
- is WavlakeSearchHit.Artist -> ArtistRow(
- name = hit.artist.name,
- genre = hit.artist.genre,
- imageUrl = hit.artist.imageUrl,
- artistId = hit.artist.id,
- address = addresses[hit.artist.id],
- onClick = {
- viewModel.openArtist(hit.artist.id, hit.artist.name)
- },
+ if (r.stations.isNotEmpty()) {
+ item(key = "sec-stations", contentType = "header") {
+ SectionHeader("Stations")
+ }
+ items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
+ Card(
+ onClick = { viewModel.playFoundStation(st) },
+ 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 = 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 = {
- viewModel.resolveArtistAddress(hit.artist.id)
- pendingZapArtistId = hit.artist.id
+ viewModel.resolveArtistAddress(artist.id)
+ pendingZapArtistId = artist.id
},
)
- is WavlakeSearchHit.Album -> AlbumRow(
- title = hit.title,
- artist = hit.artist,
- artworkUrl = hit.artworkUrl,
- onClick = { /* albums open via artist detail */ },
- )
- is WavlakeSearchHit.Track -> TrackRow(
+ }
+ }
+ if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
+ item(key = "sec-tracks", contentType = "header") {
+ SectionHeader("Tracks")
+ }
+ items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
+ TrackRow(
track = com.rada.domain.model.WavlakeTrack(
id = hit.track.id,
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(
val artistId: String,
val artistName: String,
diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
index c76862e..819afad 100644
--- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
+++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
@@ -26,10 +26,27 @@ import javax.inject.Inject
/** Curated genre shortcuts — exact Wavlake catalog names only. */
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 = emptyList(),
+ val tracks: List = emptyList(),
+ val albums: List = emptyList(),
+ val genres: List = emptyList(),
+ val stations: List = emptyList(),
+) {
+ fun isEmpty() = artists.isEmpty() && tracks.isEmpty() && albums.isEmpty() &&
+ genres.isEmpty() && stations.isEmpty()
+}
+
sealed interface WavlakeSearchState {
data object Idle : WavlakeSearchState
data object Loading : WavlakeSearchState
- data class Results(val hits: List) : WavlakeSearchState
+ data class Results(val results: DiscoverResults) : WavlakeSearchState
data class Error(val message: String) : WavlakeSearchState
}
@@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor(
private val repository: WavlakeRepository,
private val playbackManager: PlaybackManager,
private val playlists: PlaylistRepository,
+ private val stations: com.rada.data.repository.StationRepository,
+ @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow = _searchQuery.asStateFlow()
+ /** Which result types to search/show; persisted so the choice sticks. */
+ private val _selectedTypes = MutableStateFlow(loadTypes())
+ val selectedTypes: StateFlow> = _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 {
+ 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.Idle)
val searchState: StateFlow = _searchState.asStateFlow()
@@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor(
return@onEach
}
_searchState.value = WavlakeSearchState.Loading
- _searchState.value = when (val r = repository.search(query)) {
- is WavlakeResult.Ok ->
- if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
- else WavlakeSearchState.Results(r.value)
- is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
+ val types = _selectedTypes.value
+ // Local stations need no network; Wavlake hit types share one call.
+ val needRemote = types.any {
+ it != DiscoverType.GENRES && it != DiscoverType.STATIONS
}
+ 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()
+ .filter { DiscoverType.ARTISTS in types }.map { it.artist },
+ tracks = hits.filterIsInstance()
+ .filter { DiscoverType.TRACKS in types },
+ albums = hits.filterIsInstance()
+ .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)
refreshRankings(null)
}
+ /** Genres matching the query: curated discover genres + every station genre. */
+ private fun genreMatches(query: String): List {
+ 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) {
_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) {
viewModelScope.launch {
_notice.value = when (val r = repository.importTrack(track)) {
diff --git a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt
index 55ccca1..04db2c6 100644
--- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt
+++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt
@@ -1,5 +1,6 @@
package com.rada.ui.library
+import androidx.compose.animation.Crossfade
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.Close
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.FavoriteBorder
import androidx.compose.material.icons.filled.MusicNote
@@ -107,6 +109,7 @@ fun LibraryScreen(
var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showImportMenu by remember { mutableStateOf(false) }
+ var showMoreMenu by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) }
var playlistToDelete by remember { mutableStateOf(null) }
var songForPlaylist by remember { mutableStateOf(null) }
@@ -154,7 +157,14 @@ fun LibraryScreen(
Scaffold(
topBar = {
TopAppBar(
- title = { Text("Library") },
+ title = {
+ Text(
+ "Library",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.Bold,
+ )
+ },
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
@@ -197,45 +207,75 @@ fun LibraryScreen(
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
}
}
- IconButton(
- onClick = { viewModel.refreshImportedFolders() },
- enabled = !uiState.isImporting,
- ) {
- Icon(
- Icons.Default.Refresh,
- contentDescription = "Refresh imported folders",
- tint = if (uiState.isImporting) {
- LocalTokens.current.textSecondary.copy(alpha = 0.4f)
- } else {
- LocalTokens.current.textSecondary
- },
- )
- }
- // 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,
+ // Refresh / delete-folder / multi-select live in one
+ // overflow menu so the title keeps its full width instead
+ // of wrapping mid-word ("Libr|ary"). All still reachable.
+ Box {
+ IconButton(onClick = { showMoreMenu = true }) {
+ Icon(
+ Icons.Default.MoreVert,
+ contentDescription = "More library actions",
+ tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
+ 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,
+ )
+ },
+ )
+ }
+ }
+ if (tabLabels.size > 1) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ if (uiState.isMultiSelect) "Exit multi-select"
+ else "Select multiple folders",
+ )
+ },
+ onClick = {
+ showMoreMenu = false
+ if (uiState.isMultiSelect) viewModel.exitMultiSelect()
+ else viewModel.enterMultiSelect()
+ },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Checklist,
+ contentDescription = null,
+ tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
+ else LocalTokens.current.textSecondary,
+ )
+ },
)
}
}
}
- // Multi-select: combine several folders, shuffle the union.
- if (tabLabels.size > 1) {
- IconButton(onClick = {
- if (uiState.isMultiSelect) viewModel.exitMultiSelect()
- else viewModel.enterMultiSelect()
- }) {
- Icon(
- Icons.Default.Checklist,
- contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists",
- tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
- )
- }
- }
IconButton(onClick = { showAppearance = true }) {
Icon(
Icons.Default.Settings,
@@ -284,16 +324,20 @@ fun LibraryScreen(
.padding(horizontal = 16.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 = t,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = LocalTokens.current.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
Text(
- text = headerTitle,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = LocalTokens.current.text,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = "${uiState.songs.size} songs",
+ text = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
)
@@ -477,8 +521,12 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(),
) {
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(
song = song,
+ modifier = rowMod,
isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
@@ -768,10 +816,11 @@ private fun SongListItem(
onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit,
onDelete: () -> Unit,
+ modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
Row(
- modifier = Modifier
+ modifier = modifier
.fillMaxWidth()
.pressDip(interaction, LocalReduceMotion.current)
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)
diff --git a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
index feb5d55..3f7414a 100644
--- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
+++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
@@ -30,6 +30,7 @@ import com.rada.ui.player.NowPlayingBar
import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen
+import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch
@@ -49,14 +50,26 @@ fun TabsScreen(
) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope()
+ val reduceMotion = LocalReduceMotion.current
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
- // Tab taps land immediately. Swiping remains animated, but animating a
- // tap composes and disposes heavy pages while the gesture is in flight.
+ // Adjacent tabs glide (neighbors stay composed, so it's smooth); far
+ // 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) {
if (pagerState.currentPage == page) return
scope.launch {
- pagerState.scrollToPage(page)
+ 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)
+ }
}
}
diff --git a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt
index ea0ff8f..9db2807 100644
--- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt
+++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt
@@ -84,6 +84,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -440,41 +441,49 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
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 height = size.height
val centerY = height / 2
- // Draw multiple wave layers
- for (layer in 0..2) {
- 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)),
- )
+ for ((path, strokeBrush, strokeWidth) in layers) {
+ drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth))
}
// Ambient glow behind the waves — wide, faint, no visible disc edge.
diff --git a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
index c987284..7d44d55 100644
--- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
+++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
@@ -1,5 +1,8 @@
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.border
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.material3.AlertDialog
import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
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.unit.dp
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). */
internal val AccentSwatches = mapOf(
@@ -52,6 +65,46 @@ fun AppearanceDialog(
val context = LocalContext.current
val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
+ val scope = rememberCoroutineScope()
+
+ // Update flow state: idle | checking | downloading(progress) | ready | error | upToDate
+ var updateStatus by remember { mutableStateOf(null) }
+ var updateProgress by remember { mutableFloatStateOf(-1f) }
+ var updateInfo by remember { mutableStateOf(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(
onDismissRequest = onDismiss,
@@ -141,6 +194,52 @@ fun AppearanceDialog(
fontSize = 12.sp,
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 = {
diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt
index 709aec3..28df9ed 100644
--- a/app/src/main/java/com/rada/ui/theme/Motion.kt
+++ b/app/src/main/java/com/rada/ui/theme/Motion.kt
@@ -1,6 +1,7 @@
package com.rada.ui.theme
import androidx.compose.ui.unit.dp
+import androidx.compose.runtime.Composable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
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
* 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(
interactionSource: MutableInteractionSource,
reduce: Boolean,
-): Modifier = composed {
- if (reduce) return@composed this
+): Modifier {
+ if (reduce) return this
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
@@ -145,7 +149,7 @@ fun Modifier.pressDip(
),
label = "pressDip",
)
- this.graphicsLayer { scaleX = scale; scaleY = scale }
+ return this.graphicsLayer { scaleX = scale; scaleY = scale }
}
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
diff --git a/app/src/main/java/com/rada/update/UpdateChecker.kt b/app/src/main/java/com/rada/update/UpdateChecker.kt
new file mode 100644
index 0000000..d62094b
--- /dev/null
+++ b/app/src/main/java/com/rada/update/UpdateChecker.kt
@@ -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 = 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
+ }
+}
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 1c0f03c..7f63d63 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..7f63d63
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 1c3771c..5d2061f 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..5d2061f
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 930a353..5b9375b 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..5b9375b
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 07bd17b..dd56295 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..dd56295
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 21ea8c2..98c0ee5 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..98c0ee5
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..caa7643
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt
new file mode 100644
index 0000000..0295e72
--- /dev/null
+++ b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt
@@ -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"))
+ }
+}