diff --git a/Satsamp Icon.png b/Satsamp Icon.png
deleted file mode 100644
index 4237cb2..0000000
Binary files a/Satsamp Icon.png and /dev/null differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 957223e..ab2502d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,7 +54,6 @@ android {
buildFeatures {
compose = true
- buildConfig = true
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b5fe611..b61c366 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,8 +8,6 @@
-
-
@@ -50,17 +48,6 @@
-
-
-
-
-
diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt
index 1bacbd6..d656776 100644
--- a/app/src/main/java/com/rada/player/PlaybackManager.kt
+++ b/app/src/main/java/com/rada/player/PlaybackManager.kt
@@ -465,18 +465,11 @@ 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
@@ -493,7 +486,6 @@ 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)
@@ -503,7 +495,6 @@ 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 5c3e899..989e393 100644
--- a/app/src/main/java/com/rada/recording/IcyStreamReader.kt
+++ b/app/src/main/java/com/rada/recording/IcyStreamReader.kt
@@ -18,12 +18,6 @@ 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
@@ -58,7 +52,6 @@ class IcyStreamReader(
fun start(streamUrl: String) {
if (isRunning) stop()
- requestedStop = false
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope?.launch {
try {
@@ -66,10 +59,8 @@ class IcyStreamReader(
} catch (e: CancellationException) {
// Normal stop
} catch (e: Exception) {
- if (!requestedStop) {
- Log.e(TAG, "Error reading stream", e)
- listener?.onError(e.message ?: "Connection error")
- }
+ Log.e(TAG, "Error reading stream", e)
+ listener?.onError(e.message ?: "Connection error")
} finally {
isRunning = false
}
@@ -77,7 +68,6 @@ 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 6a5d856..a137c94 100644
--- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
+++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
@@ -23,7 +23,6 @@ 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
@@ -62,7 +61,6 @@ 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
@@ -206,25 +204,6 @@ 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(
@@ -268,142 +247,33 @@ fun DiscoverScreen(
}
}
is WavlakeSearchState.Results -> {
- val r = s.results
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
- 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) },
+ 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)
+ },
onZap = {
- viewModel.resolveArtistAddress(artist.id)
- pendingZapArtistId = artist.id
+ viewModel.resolveArtistAddress(hit.artist.id)
+ pendingZapArtistId = hit.artist.id
},
)
- }
- }
- 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(
+ is WavlakeSearchHit.Album -> AlbumRow(
+ title = hit.title,
+ artist = hit.artist,
+ artworkUrl = hit.artworkUrl,
+ onClick = { /* albums open via artist detail */ },
+ )
+ is WavlakeSearchHit.Track -> TrackRow(
track = com.rada.domain.model.WavlakeTrack(
id = hit.track.id,
title = hit.track.title,
@@ -447,14 +317,6 @@ 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 */ },
- )
- }
}
}
}
@@ -472,18 +334,6 @@ 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 819afad..c76862e 100644
--- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
+++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
@@ -26,27 +26,10 @@ 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 results: DiscoverResults) : WavlakeSearchState
+ data class Results(val hits: List) : WavlakeSearchState
data class Error(val message: String) : WavlakeSearchState
}
@@ -56,39 +39,11 @@ 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()
@@ -134,60 +89,17 @@ class WavlakeViewModel @Inject constructor(
return@onEach
}
_searchState.value = WavlakeSearchState.Loading
- 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
+ _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 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
}
@@ -308,31 +220,6 @@ 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 04db2c6..55ccca1 100644
--- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt
+++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt
@@ -1,6 +1,5 @@
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
@@ -29,7 +28,6 @@ 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
@@ -109,7 +107,6 @@ 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) }
@@ -157,14 +154,7 @@ fun LibraryScreen(
Scaffold(
topBar = {
TopAppBar(
- title = {
- Text(
- "Library",
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- fontWeight = FontWeight.Bold,
- )
- },
+ title = { Text("Library") },
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
@@ -207,75 +197,45 @@ fun LibraryScreen(
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
}
}
- // 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,
- )
- },
+ 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,
)
}
}
}
+ // 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,
@@ -324,20 +284,16 @@ 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 = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}",
+ text = headerTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = LocalTokens.current.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = "${uiState.songs.size} songs",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
)
@@ -521,12 +477,8 @@ 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) },
@@ -816,11 +768,10 @@ 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 3f7414a..feb5d55 100644
--- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
+++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
@@ -30,7 +30,6 @@ 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
@@ -50,26 +49,14 @@ 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) }
- // 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.
+ // Tab taps land immediately. Swiping remains animated, but animating a
+ // tap composes and disposes heavy pages while the gesture is in flight.
fun goToPage(page: Int) {
if (pagerState.currentPage == page) return
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)
}
}
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 9db2807..ea0ff8f 100644
--- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt
+++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt
@@ -84,7 +84,6 @@ 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
@@ -441,49 +440,41 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
val accentGlow = LocalTokens.current.accentFill
- // 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 },
- ) {
+ Canvas(modifier = modifier) {
val width = size.width
val height = size.height
val centerY = height / 2
- for ((path, strokeBrush, strokeWidth) in layers) {
- drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth))
+ // 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)),
+ )
}
// 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 7d44d55..c987284 100644
--- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
+++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt
@@ -1,8 +1,5 @@
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
@@ -21,17 +18,10 @@ 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
@@ -40,9 +30,6 @@ 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(
@@ -65,46 +52,6 @@ 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,
@@ -194,52 +141,6 @@ 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 28df9ed..709aec3 100644
--- a/app/src/main/java/com/rada/ui/theme/Motion.kt
+++ b/app/src/main/java/com/rada/ui/theme/Motion.kt
@@ -1,7 +1,6 @@
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
@@ -131,15 +130,12 @@ 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 {
- if (reduce) return this
+): Modifier = composed {
+ if (reduce) return@composed this
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
@@ -149,7 +145,7 @@ fun Modifier.pressDip(
),
label = "pressDip",
)
- return this.graphicsLayer { scaleX = scale; scaleY = scale }
+ 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
deleted file mode 100644
index d62094b..0000000
--- a/app/src/main/java/com/rada/update/UpdateChecker.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-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 7f63d63..1c0f03c 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
deleted file mode 100644
index 7f63d63..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 5d2061f..1c3771c 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
deleted file mode 100644
index 5d2061f..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 5b9375b..930a353 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
deleted file mode 100644
index 5b9375b..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index dd56295..07bd17b 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
deleted file mode 100644
index dd56295..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 98c0ee5..21ea8c2 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
deleted file mode 100644
index 98c0ee5..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
deleted file mode 100644
index caa7643..0000000
--- a/app/src/main/res/xml/file_paths.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt
deleted file mode 100644
index 0295e72..0000000
--- a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-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"))
- }
-}